diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java
index b88e1416da..3a792f12f5 100644
--- a/app/src/processing/app/Preferences.java
+++ b/app/src/processing/app/Preferences.java
@@ -23,62 +23,34 @@
import processing.app.ui.Toolkit;
import processing.core.PApplet;
-import processing.core.PConstants;
-import java.awt.*;
-import java.io.*;
-import java.util.HashMap;
-import java.util.Map;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.SystemColor;
+import java.io.IOException;
+import java.io.InputStream;
/**
- * Storage class for user preferences and environment settings.
- *
- * This class does not use the Properties class because .properties files use
- * ISO 8859-1 encoding, which is highly likely to be a problem when trying to
- * save sketch folders and locations. Like the rest of Processing, we use UTF8.
- *
- * We don't use the Java Preferences API because it would entail writing to
- * the registry (on Windows), or an obscure file location (on Mac OS X) and
- * make it far more difficult (impossible) to remove the preferences.txt to
- * reset them (when they become corrupt), or to find the the file to make
- * edits for numerous obscure preferences that are not part of the preferences
- * window. If we added a generic editor (e.g. about:config in Mozilla) for
- * such things, we could start using the Java Preferences API. But wow, that
- * sounds like a lot of work. Not unlike writing this paragraph.
+ * The app-facing side of the preferences. The actual storage (loading and
+ * saving preferences.txt, the bundled defaults, platform-specific keys)
+ * lives in the standalone {@link processing.utils.Preferences}; this class
+ * delegates to it and keeps the pieces that only make sense inside the
+ * full application: fonts and colors, error dialogs, and environment
+ * side effects like proxies and the native file chooser.
*/
public class Preferences {
- // had to rename the defaults file because people were editing it
- static final String DEFAULTS_FILE = "defaults.txt"; //$NON-NLS-1$
- static final String PREFS_FILE = "preferences.txt"; //$NON-NLS-1$
- static Map defaults;
- static Map table = new HashMap<>();
- static File preferencesFile;
- private static boolean initialized = false;
-
-
-// /** @return true if the sketchbook file did not exist */
-// static public boolean init() {
static public void init() {
- initialized = true;
// start by loading the defaults, in case something
// important was deleted from the user prefs
try {
- var defaultsStream = Preferences
- .class
- .getClassLoader()
- .getResourceAsStream(DEFAULTS_FILE);
- load(defaultsStream);
- } catch (Exception e) {
+ processing.utils.Preferences.loadDefaults();
+ } catch (IOException e) {
Messages.showError(null, "Could not read default settings.\n" +
"You'll need to reinstall Processing.", e);
}
- // Clone the defaults, then override any them with the user's preferences.
- // This ensures that any new/added preference will be present.
- defaults = new HashMap<>(table);
-
// other things that have to be set explicitly for the defaults
setColor("run.window.bgcolor", SystemColor.control); //$NON-NLS-1$
@@ -87,32 +59,15 @@ static public void init() {
setBoolean("editor.input_method_support", true);
}
-
- // next load user preferences file
- preferencesFile = Base.getSettingsFile(PREFS_FILE);
- var preferencesFileOverride = System.getProperty("processing.app.preferences.file");
- if (preferencesFileOverride != null && !preferencesFileOverride.isEmpty()) {
- preferencesFile = new File(preferencesFileOverride);
- }
- boolean firstRun = !preferencesFile.exists();
- if (!firstRun) {
- try {
- load(new FileInputStream(preferencesFile));
-
- } catch (Exception ex) {
- Messages.showError("Error reading preferences",
- "Error reading the preferences file. " +
- "Please delete (or move)\n" +
- preferencesFile.getAbsolutePath() +
- " and restart Processing.", ex);
- }
- }
-
- if (checkSketchbookPref() || firstRun) {
-// if (firstRun) {
- // create a new preferences file if none exists
- // saves the defaults out to the file
- save();
+ // next load user preferences file over the defaults
+ try {
+ processing.utils.Preferences.loadUserPrefs(Base.getSettingsFolder());
+ } catch (IOException ex) {
+ Messages.showError("Error reading preferences",
+ "Error reading the preferences file. " +
+ "Please delete (or move)\n" +
+ processing.utils.Preferences.getPreferencesPath() +
+ " and restart Processing.", ex);
}
PApplet.useNativeSelect =
@@ -140,7 +95,7 @@ static public void init() {
* For testing, pretend to load preferences without a real file.
*/
static public void skipInit() {
- initialized = true;
+ processing.utils.Preferences.skipInit();
}
/**
@@ -148,7 +103,7 @@ static public void skipInit() {
* @return true if Preferences has been initialized
*/
static public boolean isInitialized() {
- return initialized;
+ return processing.utils.Preferences.isInitialized();
}
@@ -165,120 +120,24 @@ static void handleProxy(String protocol, String hostProp, String portProp) {
static public String getPreferencesPath() {
- return preferencesFile.getAbsolutePath();
+ return processing.utils.Preferences.getPreferencesPath();
}
// . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
- /**
- * Load a set of key/value pairs from a UTF-8 encoded file into 'table'.
- * For 3.0a6, this removes any platform-specific extensions from keys, so
- * that we don't have platform-specific entries in a user's preferences.txt
- * file, which would require all prefs to be changed twice, or risk being
- * overwritten by the unchanged platform-specific version on reload.
- */
static public void load(InputStream input) throws IOException {
- HashMap platformSpecific = new HashMap<>();
-
- String[] lines = PApplet.loadStrings(input); // Reads as UTF-8
- for (String line : lines) {
- if ((line.isEmpty()) ||
- (line.charAt(0) == '#')) continue;
-
- line = line.replace("\\", "/"); // normalize slashes in paths
-
- // this won't properly handle = signs being in the text
- int equals = line.indexOf('=');
- if (equals != -1) {
- String key = line.substring(0, equals).trim();
- String value = line.substring(equals + 1).trim();
- if (!isPlatformSpecific(key, value, platformSpecific)) {
- table.put(key, value);
- }
- }
- }
- // Now override the keys with any platform-specific defaults we've found.
- for (String key : platformSpecific.keySet()) {
- table.put(key, platformSpecific.get(key));
- }
+ processing.utils.Preferences.load(input);
}
- /**
- * @param key original key (may include platform extension)
- * @param value the value that goes with the key
- * @param specific where to put the key/value pairs for *this* platform
- * @return true if a platform-specific key
- */
- static protected boolean isPlatformSpecific(String key, String value,
- Map specific) {
- for (String platform : PConstants.platformNames) {
- String ext = "." + platform;
- if (key.endsWith(ext)) {
- String thisPlatform = PConstants.platformNames[PApplet.platform];
- if (platform.equals(thisPlatform)) {
- key = key.substring(0, key.lastIndexOf(ext));
- // store this for later overrides
- specific.put(key, value);
- //} else {
- // ignore platform-specific defaults for other platforms,
- // but return 'true' because it needn't be added to the big list
- }
- return true;
- }
- }
- return false;
- }
-
-
- // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
-
-
static public void save() {
- // On startup, this is null, but ignore it. It's trying to update the
- // prefs for the open sketch before Preferences.init() has been called.
- if (preferencesFile != null) {
- try {
- File dir = preferencesFile.getParentFile();
- File preferencesTemp = File.createTempFile("preferences", ".txt", dir);
- if (!preferencesTemp.setWritable(true, false)) {
- throw new IOException("Could not set " + preferencesTemp + " writable");
- }
-
- // Fix for 0163 to properly use Unicode when writing preferences.txt
- PrintWriter writer = PApplet.createWriter(preferencesTemp);
-
- String[] keyList = table.keySet().toArray(new String[0]);
- // Sorting is really helpful for debugging, diffing, and finding keys
- keyList = PApplet.sort(keyList);
- for (String key : keyList) {
- writer.println(key + "=" + table.get(key)); //$NON-NLS-1$
- }
- writer.flush();
- writer.close();
-
- // Rename preferences.txt to preferences.old
- File oldPreferences = new File(dir, "preferences.old");
- if (oldPreferences.exists()) {
- if (!oldPreferences.delete()) {
- throw new IOException("Could not delete preferences.old");
- }
- }
- if (preferencesFile.exists() &&
- !preferencesFile.renameTo(oldPreferences)) {
- throw new IOException("Could not replace preferences.old");
- }
- // Make the temporary file into the real preferences
- if (!preferencesTemp.renameTo(preferencesFile)) {
- throw new IOException("Could not move preferences file into place");
- }
-
- } catch (IOException e) {
- Messages.showWarning("Preferences",
- "Could not save the Preferences file.", e);
- }
+ try {
+ processing.utils.Preferences.save();
+ } catch (IOException e) {
+ Messages.showWarning("Preferences",
+ "Could not save the Preferences file.", e);
}
}
@@ -289,44 +148,31 @@ static public void save() {
// all the information from preferences.txt
static public String get(String attribute /*, String defaultValue */) {
- if (!initialized) {
+ if (!isInitialized()) {
init();
}
- return table.get(attribute);
+ return processing.utils.Preferences.get(attribute);
}
static public String getDefault(String attribute) {
- return defaults.get(attribute);
+ return processing.utils.Preferences.getDefault(attribute);
}
static public void set(String attribute, String value) {
- table.put(attribute, value);
+ processing.utils.Preferences.set(attribute, value);
}
static public void unset(String attribute) {
- table.remove(attribute);
+ processing.utils.Preferences.unset(attribute);
}
static public boolean getBoolean(String attribute) {
String value = get(attribute); //, null);
return Boolean.parseBoolean(value);
-
- /*
- supposedly not needed, because anything besides 'true'
- (ignoring case) will just be false.. so if malformed -> false
- if (value == null) return defaultValue;
-
- try {
- return (new Boolean(value)).booleanValue();
- } catch (NumberFormatException e) {
- System.err.println("expecting an integer: " + attribute + " = " + value);
- }
- return defaultValue;
- */
}
@@ -385,26 +231,6 @@ static public Font getFont(String familyAttr, String sizeAttr, int style) {
// . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
- /**
- * Check for a 4.0 sketchbook location, and if none exists,
- * try to grab it from the 3.0 sketchbook location.
- * @return true if a location was found and the pref didn't exist
- */
- static protected boolean checkSketchbookPref() {
- // If a 4.0 sketchbook location has never been inited
- if (getSketchbookPath() == null) {
- String threePath = get("sketchbook.path.three"); //$NON-NLS-1$
- // If they've run the 3.0 version, start with that location
- if (threePath != null) {
- setSketchbookPath(threePath);
- return true; // save the sketchbook right away
- }
- // Otherwise it'll be null, and reset properly by Base
- }
- return false;
- }
-
-
static public String getOldSketchbookPath() {
return get("sketchbook.path.three"); //$NON-NLS-1$
}
diff --git a/app/utils/src/main/java/processing/utils/Platform.java b/app/utils/src/main/java/processing/utils/Platform.java
index 497613e51a..2c117f082d 100644
--- a/app/utils/src/main/java/processing/utils/Platform.java
+++ b/app/utils/src/main/java/processing/utils/Platform.java
@@ -23,4 +23,16 @@ static public boolean isWindows() {
static public boolean isLinux() {
return System.getProperty("os.name").contains("Linux"); //$NON-NLS-1$ //$NON-NLS-2$
}
+
+
+ /**
+ * Platform name for the current OS, mirroring core's
+ * PConstants.platformNames: "windows", "macos", "linux", or "other".
+ */
+ static public String getName() {
+ if (isWindows()) return "windows"; //$NON-NLS-1$
+ if (isMacOS()) return "macos"; //$NON-NLS-1$
+ if (isLinux()) return "linux"; //$NON-NLS-1$
+ return "other"; //$NON-NLS-1$
+ }
}
diff --git a/app/utils/src/main/java/processing/utils/Preferences.java b/app/utils/src/main/java/processing/utils/Preferences.java
new file mode 100644
index 0000000000..10ba01e27f
--- /dev/null
+++ b/app/utils/src/main/java/processing/utils/Preferences.java
@@ -0,0 +1,463 @@
+/* -*- mode: java; c-basic-offset: 2; indent-tabs-mode: nil -*- */
+
+/*
+ Part of the Processing project - http://processing.org
+
+ Copyright (c) 2014-19 The Processing Foundation
+
+ 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.utils;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+
+/**
+ * Storage class for user preferences and environment settings.
+ *
+ * This class does not use the Properties class because .properties files use
+ * ISO 8859-1 encoding, which is highly likely to be a problem when trying to
+ * save sketch folders and locations. Like the rest of Processing, we use UTF8.
+ *
+ * We don't use the Java Preferences API because it would entail writing to
+ * the registry (on Windows), or an obscure file location (on Mac OS X) and
+ * make it far more difficult (impossible) to remove the preferences.txt to
+ * reset them (when they become corrupt), or to find the the file to make
+ * edits for numerous obscure preferences that are not part of the preferences
+ * window. If we added a generic editor (e.g. about:config in Mozilla) for
+ * such things, we could start using the Java Preferences API. But wow, that
+ * sounds like a lot of work. Not unlike writing this paragraph.
+ *
+ * This is the standalone version: it only loads and saves preferences, and
+ * reports problems by throwing. Anything that needs the rest of the app
+ * (error dialogs, fonts, proxy setup) lives in processing.app.Preferences,
+ * which delegates the storage to this class.
+ */
+public class Preferences {
+ // had to rename the defaults file because people were editing it
+ static final String DEFAULTS_FILE = "defaults.txt"; //$NON-NLS-1$
+ static final String PREFS_FILE = "preferences.txt"; //$NON-NLS-1$
+
+ /**
+ * Suffixes for platform-specific keys in defaults.txt, mirroring
+ * PConstants.platformNames. These strings are part of the file format,
+ * so they must not change independently of core.
+ */
+ static final String[] PLATFORM_NAMES = {
+ "other", "windows", "macos", "linux" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
+ };
+
+ static Map defaults;
+ static Map table = new HashMap<>();
+ static File preferencesFile;
+ static private boolean initialized = false;
+
+ static private final List listeners =
+ new CopyOnWriteArrayList<>();
+
+
+ /**
+ * Callback for preference changes made through set() or unset().
+ * Loading (init or otherwise) does not fire events: listeners are for
+ * reacting to changes, not for observing startup.
+ */
+ public interface ChangeListener {
+ /**
+ * @param key the preference that changed
+ * @param value the new value, or null if the key was removed
+ */
+ void preferenceChanged(String key, String value);
+ }
+
+
+ static public void addChangeListener(ChangeListener listener) {
+ listeners.add(listener);
+ }
+
+
+ static public void removeChangeListener(ChangeListener listener) {
+ listeners.remove(listener);
+ }
+
+
+ static private void fireChange(String key, String value) {
+ for (ChangeListener listener : listeners) {
+ try {
+ listener.preferenceChanged(key, value);
+ } catch (Exception e) {
+ // one broken listener shouldn't prevent the change (or the others)
+ e.printStackTrace();
+ }
+ }
+ }
+
+
+ /**
+ * Load the bundled defaults followed by the user's preferences.txt from
+ * the standard per-platform settings folder, creating the user file from
+ * the defaults on first run. One-call setup for standalone use.
+ */
+ static public void init() throws IOException {
+ init(null);
+ }
+
+
+ /**
+ * Same as {@link #init()}, but with the settings folder passed in by the
+ * caller instead of resolved via {@link Settings#getFolder()}. The app
+ * calls the stages separately so it can set its own defaults in between.
+ * @param settingsFolder folder containing preferences.txt, or null to
+ * resolve the standard location for this platform
+ */
+ static public void init(File settingsFolder) throws IOException {
+ loadDefaults();
+ loadUserPrefs(settingsFolder);
+ }
+
+
+ /**
+ * First init stage: read the bundled defaults from the classpath.
+ * Also keeps a copy of the defaults, so that any new/added preference
+ * will be present even if missing from the user's preferences file.
+ */
+ static public void loadDefaults() throws IOException {
+ initialized = true;
+
+ // Name changed for 2.1b2 to avoid problems with users modifying or
+ // replacing the file after doing a search for "preferences.txt".
+ try (InputStream input =
+ Preferences.class.getClassLoader().getResourceAsStream(DEFAULTS_FILE)) {
+ if (input == null) {
+ throw new IOException("Could not find " + DEFAULTS_FILE + " on the classpath");
+ }
+ load(input);
+ }
+
+ // Clone the defaults, then override them with the user's preferences
+ // as they are loaded on top in the next stage.
+ defaults = new HashMap<>(table);
+ }
+
+
+ /**
+ * Second init stage: read the user's preferences.txt over the defaults,
+ * and write the file out if it did not exist yet (or a 3.x sketchbook
+ * location was migrated).
+ * @param settingsFolder folder containing preferences.txt, or null to
+ * resolve the standard location for this platform
+ */
+ static public void loadUserPrefs(File settingsFolder) throws IOException {
+ preferencesFile = resolvePreferencesFile(settingsFolder);
+ boolean firstRun = !preferencesFile.exists();
+ if (!firstRun) {
+ try (InputStream input = new FileInputStream(preferencesFile)) {
+ load(input);
+ } catch (IOException e) {
+ throw new IOException("Could not read " +
+ preferencesFile.getAbsolutePath(), e);
+ }
+ }
+
+ if (checkSketchbookPref() || firstRun) {
+ // create a new preferences file if none exists
+ // saves the defaults out to the file
+ save();
+ }
+ }
+
+
+ /**
+ * Resolve the user preferences file: an explicit system property override
+ * wins, then the folder passed by the caller, then the standard
+ * per-platform settings folder.
+ */
+ static File resolvePreferencesFile(File settingsFolder) throws IOException {
+ String override = System.getProperty("processing.app.preferences.file");
+ if (override != null && !override.isEmpty()) {
+ return new File(override);
+ }
+ if (settingsFolder != null) {
+ return new File(settingsFolder, PREFS_FILE);
+ }
+ try {
+ return new File(Settings.getFolder(), PREFS_FILE);
+ } catch (Settings.SettingsFolderException e) {
+ throw new IOException("Could not locate the settings folder", e);
+ }
+ }
+
+
+ /**
+ * For testing, pretend to load preferences without a real file.
+ */
+ static public void skipInit() {
+ initialized = true;
+ }
+
+
+ /**
+ * For testing, forget all state so the next test starts clean.
+ */
+ static void reset() {
+ table.clear();
+ defaults = null;
+ preferencesFile = null;
+ initialized = false;
+ listeners.clear();
+ }
+
+
+ /**
+ * Check whether init() has been called. If not, we are probably not
+ * running the full application.
+ * @return true if Preferences has been initialized
+ */
+ static public boolean isInitialized() {
+ return initialized;
+ }
+
+
+ static public String getPreferencesPath() {
+ return preferencesFile.getAbsolutePath();
+ }
+
+
+ // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
+
+
+ /**
+ * Load a set of key/value pairs from a UTF-8 encoded stream into 'table'.
+ * For 3.0a6, this removes any platform-specific extensions from keys, so
+ * that we don't have platform-specific entries in a user's preferences.txt
+ * file, which would require all prefs to be changed twice, or risk being
+ * overwritten by the unchanged platform-specific version on reload.
+ */
+ static public void load(InputStream input) throws IOException {
+ HashMap platformSpecific = new HashMap<>();
+
+ // Closes the stream when done, like PApplet.loadStrings() did here.
+ try (BufferedReader reader =
+ new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if ((line.isEmpty()) ||
+ (line.charAt(0) == '#')) continue;
+
+ line = line.replace("\\", "/"); // normalize slashes in paths
+
+ // this won't properly handle = signs being in the text
+ int equals = line.indexOf('=');
+ if (equals != -1) {
+ String key = line.substring(0, equals).trim();
+ String value = line.substring(equals + 1).trim();
+ if (!isPlatformSpecific(key, value, platformSpecific)) {
+ table.put(key, value);
+ }
+ }
+ }
+ }
+ // Now override the keys with any platform-specific defaults we've found.
+ for (String key : platformSpecific.keySet()) {
+ table.put(key, platformSpecific.get(key));
+ }
+ }
+
+
+ /**
+ * @param key original key (may include platform extension)
+ * @param value the value that goes with the key
+ * @param specific where to put the key/value pairs for *this* platform
+ * @return true if a platform-specific key
+ */
+ static protected boolean isPlatformSpecific(String key, String value,
+ Map specific) {
+ for (String platform : PLATFORM_NAMES) {
+ String ext = "." + platform;
+ if (key.endsWith(ext)) {
+ if (platform.equals(Platform.getName())) {
+ key = key.substring(0, key.lastIndexOf(ext));
+ // store this for later overrides
+ specific.put(key, value);
+ //} else {
+ // ignore platform-specific defaults for other platforms,
+ // but return 'true' because it needn't be added to the big list
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
+
+
+ static public void save() throws IOException {
+ // On startup, this is null, but ignore it. It's trying to update the
+ // prefs for the open sketch before init() has been called.
+ if (preferencesFile != null) {
+ File dir = preferencesFile.getParentFile();
+ // standalone callers may pass a settings folder that doesn't exist yet
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Could not create " + dir);
+ }
+ File preferencesTemp = File.createTempFile("preferences", ".txt", dir);
+ if (!preferencesTemp.setWritable(true, false)) {
+ throw new IOException("Could not set " + preferencesTemp + " writable");
+ }
+
+ // Fix for 0163 to properly use Unicode when writing preferences.txt
+ try (PrintWriter writer = new PrintWriter(
+ new OutputStreamWriter(new FileOutputStream(preferencesTemp),
+ StandardCharsets.UTF_8))) {
+ String[] keyList = table.keySet().toArray(new String[0]);
+ // Sorting is really helpful for debugging, diffing, and finding keys
+ Arrays.sort(keyList);
+ for (String key : keyList) {
+ writer.println(key + "=" + table.get(key)); //$NON-NLS-1$
+ }
+ writer.flush();
+ }
+
+ // Rename preferences.txt to preferences.old
+ File oldPreferences = new File(dir, "preferences.old");
+ if (oldPreferences.exists()) {
+ if (!oldPreferences.delete()) {
+ throw new IOException("Could not delete preferences.old");
+ }
+ }
+ if (preferencesFile.exists() &&
+ !preferencesFile.renameTo(oldPreferences)) {
+ throw new IOException("Could not replace preferences.old");
+ }
+ // Make the temporary file into the real preferences
+ if (!preferencesTemp.renameTo(preferencesFile)) {
+ throw new IOException("Could not move preferences file into place");
+ }
+ }
+ }
+
+
+ // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
+
+
+ // all the information from preferences.txt
+
+ static public String get(String attribute) {
+ return table.get(attribute);
+ }
+
+
+ static public String getDefault(String attribute) {
+ return defaults.get(attribute);
+ }
+
+
+ static public void set(String attribute, String value) {
+ String previous = table.put(attribute, value);
+ if (!Objects.equals(previous, value)) {
+ fireChange(attribute, value);
+ }
+ }
+
+
+ static public void unset(String attribute) {
+ if (table.remove(attribute) != null) {
+ fireChange(attribute, null);
+ }
+ }
+
+
+ static public boolean getBoolean(String attribute) {
+ String value = get(attribute);
+ return Boolean.parseBoolean(value);
+ }
+
+
+ static public void setBoolean(String attribute, boolean value) {
+ set(attribute, value ? "true" : "false"); //$NON-NLS-1$ //$NON-NLS-2$
+ }
+
+
+ static public int getInteger(String attribute) {
+ try {
+ return Integer.parseInt(get(attribute));
+ } catch (NumberFormatException err) {
+ try {
+ return Integer.parseInt(getDefault(attribute));
+ } catch (NumberFormatException err2) {
+ throw new IllegalArgumentException("Cannot parse: " + attribute);
+ }
+ }
+ }
+
+
+ static public void setInteger(String key, int value) {
+ set(key, String.valueOf(value));
+ }
+
+
+ // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
+
+
+ /**
+ * Check for a 4.0 sketchbook location, and if none exists,
+ * try to grab it from the 3.0 sketchbook location.
+ * @return true if a location was found and the pref didn't exist
+ */
+ static protected boolean checkSketchbookPref() {
+ // If a 4.0 sketchbook location has never been inited
+ if (getSketchbookPath() == null) {
+ String threePath = get("sketchbook.path.three"); //$NON-NLS-1$
+ // If they've run the 3.0 version, start with that location
+ if (threePath != null) {
+ setSketchbookPath(threePath);
+ return true; // save the sketchbook right away
+ }
+ // Otherwise it'll be null, and reset properly by the application
+ }
+ return false;
+ }
+
+
+ static public String getOldSketchbookPath() {
+ return get("sketchbook.path.three"); //$NON-NLS-1$
+ }
+
+
+ static public String getSketchbookPath() {
+ return get("sketchbook.path.four"); //$NON-NLS-1$
+ }
+
+
+ static public void setSketchbookPath(String path) {
+ set("sketchbook.path.four", path); //$NON-NLS-1$
+ }
+}
diff --git a/app/src/main/resources/defaults.txt b/app/utils/src/main/resources/defaults.txt
similarity index 100%
rename from app/src/main/resources/defaults.txt
rename to app/utils/src/main/resources/defaults.txt
diff --git a/app/utils/src/test/java/processing/utils/PreferencesTest.java b/app/utils/src/test/java/processing/utils/PreferencesTest.java
new file mode 100644
index 0000000000..1cdfbab2a8
--- /dev/null
+++ b/app/utils/src/test/java/processing/utils/PreferencesTest.java
@@ -0,0 +1,316 @@
+package processing.utils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class PreferencesTest {
+
+ @TempDir
+ File settingsFolder;
+
+ @BeforeEach
+ public void clearState() {
+ Preferences.reset();
+ }
+
+ @AfterEach
+ public void clearOverride() {
+ System.clearProperty("processing.app.preferences.file");
+ Preferences.reset();
+ }
+
+
+ static InputStream stream(String content) {
+ return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+ }
+
+
+ /**
+ * The bundled defaults load from the classpath, and on a first run the
+ * table starts out identical to the defaults.
+ */
+ @Test
+ public void testInitLoadsBundledDefaults() throws IOException {
+ Preferences.init(settingsFolder);
+ assertNotNull(Preferences.get("editor.tabs.size"));
+ assertEquals(Preferences.getDefault("editor.tabs.size"),
+ Preferences.get("editor.tabs.size"));
+ assertTrue(Preferences.isInitialized());
+ }
+
+
+ /**
+ * A first run writes preferences.txt out to the settings folder.
+ */
+ @Test
+ public void testFirstRunCreatesPreferencesFile() throws IOException {
+ var file = new File(settingsFolder, "preferences.txt");
+ assertFalse(file.exists());
+ Preferences.init(settingsFolder);
+ assertTrue(file.exists());
+ }
+
+
+ /**
+ * Values set and saved come back after a fresh init.
+ */
+ @Test
+ public void testSavedValuesSurviveReload() throws IOException {
+ Preferences.init(settingsFolder);
+ Preferences.set("editor.tabs.size", "7");
+ Preferences.save();
+
+ Preferences.reset();
+ Preferences.init(settingsFolder);
+ assertEquals("7", Preferences.get("editor.tabs.size"));
+ }
+
+
+ /**
+ * An existing preferences.txt overrides the bundled defaults,
+ * but the defaults remain available via getDefault().
+ */
+ @Test
+ public void testUserPrefsOverrideDefaults() throws IOException {
+ var file = new File(settingsFolder, "preferences.txt");
+ Files.writeString(file.toPath(), "editor.tabs.size=9\n");
+
+ Preferences.init(settingsFolder);
+ assertEquals("9", Preferences.get("editor.tabs.size"));
+ assertEquals("2", Preferences.getDefault("editor.tabs.size"));
+ }
+
+
+ /**
+ * The processing.app.preferences.file system property wins over the
+ * settings folder passed in.
+ */
+ @Test
+ public void testSystemPropertyOverrideWins(@TempDir File otherFolder)
+ throws IOException {
+ var override = new File(otherFolder, "prefs-override.txt");
+ Files.writeString(override.toPath(), "override.marker=yes\n");
+ System.setProperty("processing.app.preferences.file",
+ override.getAbsolutePath());
+
+ Preferences.init(settingsFolder);
+ assertEquals("yes", Preferences.get("override.marker"));
+ assertEquals(override.getAbsolutePath(),
+ Preferences.getPreferencesPath());
+ }
+
+
+ /**
+ * Keys with a suffix for this platform apply with the suffix stripped;
+ * keys for other platforms are ignored entirely.
+ */
+ @Test
+ public void testPlatformSpecificKeys() throws IOException {
+ Preferences.skipInit();
+ Preferences.load(stream(
+ "key.windows=w\nkey.macos=m\nkey.linux=l\nkey.other=o\n"));
+
+ var expected = switch (Platform.getName()) {
+ case "windows" -> "w";
+ case "macos" -> "m";
+ case "linux" -> "l";
+ default -> "o";
+ };
+ assertEquals(expected, Preferences.get("key"));
+ assertNull(Preferences.get("key." + Platform.getName()));
+ }
+
+
+ /**
+ * Backslashes in values are normalized to forward slashes on load.
+ */
+ @Test
+ public void testBackslashesNormalized() throws IOException {
+ Preferences.skipInit();
+ Preferences.load(stream("sketchbook.path.four=C:\\Users\\test\n"));
+ assertEquals("C:/Users/test", Preferences.get("sketchbook.path.four"));
+ }
+
+
+ /**
+ * Comment lines and blank lines don't produce entries.
+ */
+ @Test
+ public void testCommentsAndBlanksIgnored() throws IOException {
+ Preferences.skipInit();
+ Preferences.load(stream("# a comment\n\nkey = value\n"));
+ assertEquals("value", Preferences.get("key"));
+ assertNull(Preferences.get("# a comment"));
+ }
+
+
+ /**
+ * getInteger() falls back to the default when the stored value
+ * doesn't parse.
+ */
+ @Test
+ public void testGetIntegerFallsBackToDefault() throws IOException {
+ Preferences.init(settingsFolder);
+ Preferences.set("editor.tabs.size", "not-a-number");
+ assertEquals(2, Preferences.getInteger("editor.tabs.size"));
+ }
+
+
+ /**
+ * An unreadable preferences.txt makes init throw instead of
+ * silently continuing.
+ */
+ @Test
+ public void testUnreadablePreferencesFileThrows() throws IOException {
+ // a directory where the file should be cannot be opened for reading
+ var blocked = new File(settingsFolder, "preferences.txt");
+ assertTrue(blocked.mkdir());
+ assertThrows(IOException.class, () -> Preferences.init(settingsFolder));
+ }
+
+
+ /**
+ * preferences.txt is written with its keys sorted.
+ */
+ @Test
+ public void testSaveWritesSortedKeys() throws IOException {
+ Preferences.init(settingsFolder);
+ Preferences.set("zzz.last", "1");
+ Preferences.set("aaa.first", "1");
+ Preferences.save();
+
+ var lines = Files.readAllLines(
+ new File(settingsFolder, "preferences.txt").toPath());
+ // sorted by key, not by line: "a.b=…" sorts after "a=…" as a line,
+ // but the keys "a" < "a.b" are what save() orders by
+ var keys = new ArrayList();
+ for (var line : lines) {
+ keys.add(line.substring(0, line.indexOf('=')));
+ }
+ var sorted = new ArrayList<>(keys);
+ sorted.sort(null);
+ assertEquals(sorted, keys);
+ }
+
+
+ /**
+ * A settings folder that doesn't exist yet is created on first save,
+ * so standalone callers can point at any location.
+ */
+ @Test
+ public void testInitCreatesMissingSettingsFolder() throws IOException {
+ var missing = new File(settingsFolder, "nested/settings");
+ assertFalse(missing.exists());
+
+ Preferences.init(missing);
+ assertTrue(new File(missing, "preferences.txt").exists());
+ }
+
+
+ /**
+ * A 3.x sketchbook location is migrated to the 4.0 key on init,
+ * and the migrated preference is written back out right away.
+ */
+ @Test
+ public void testSketchbookPathMigration() throws IOException {
+ var file = new File(settingsFolder, "preferences.txt");
+ Files.writeString(file.toPath(),
+ "sketchbook.path.three=/old/sketchbook\n");
+
+ Preferences.init(settingsFolder);
+ assertEquals("/old/sketchbook", Preferences.getSketchbookPath());
+ assertEquals("/old/sketchbook", Preferences.getOldSketchbookPath());
+
+ var saved = Files.readString(file.toPath());
+ assertTrue(saved.contains("sketchbook.path.four=/old/sketchbook"));
+ }
+
+
+ /**
+ * An existing 4.0 sketchbook location is left alone, even when a
+ * 3.x location is also present.
+ */
+ @Test
+ public void testExistingSketchbookPathNotOverwritten() throws IOException {
+ var file = new File(settingsFolder, "preferences.txt");
+ Files.writeString(file.toPath(),
+ "sketchbook.path.three=/old\nsketchbook.path.four=/new\n");
+
+ Preferences.init(settingsFolder);
+ assertEquals("/new", Preferences.getSketchbookPath());
+ }
+
+
+ /**
+ * Listeners hear set() and unset(), but not writes of the same value.
+ */
+ @Test
+ public void testListenerSemantics() {
+ Preferences.skipInit();
+ List events = new ArrayList<>();
+ Preferences.ChangeListener listener =
+ (key, value) -> events.add(key + "=" + value);
+ Preferences.addChangeListener(listener);
+
+ Preferences.set("a", "1");
+ Preferences.set("a", "1"); // unchanged, no event
+ Preferences.set("a", "2");
+ Preferences.unset("a");
+ Preferences.unset("a"); // already gone, no event
+ assertEquals(List.of("a=1", "a=2", "a=null"), events);
+
+ Preferences.removeChangeListener(listener);
+ Preferences.set("a", "3");
+ assertEquals(3, events.size());
+ }
+
+
+ /**
+ * Bulk loading does not fire change events.
+ */
+ @Test
+ public void testLoadIsSilentForListeners() throws IOException {
+ Preferences.skipInit();
+ List events = new ArrayList<>();
+ Preferences.addChangeListener((key, value) -> events.add(key));
+
+ Preferences.load(stream("a=1\nb=2\n"));
+ assertTrue(events.isEmpty());
+ }
+
+
+ /**
+ * A listener that throws doesn't block the change or other listeners.
+ */
+ @Test
+ public void testListenerExceptionContained() {
+ Preferences.skipInit();
+ List events = new ArrayList<>();
+ Preferences.addChangeListener((key, value) -> {
+ throw new RuntimeException("broken listener");
+ });
+ Preferences.addChangeListener((key, value) -> events.add(key));
+
+ Preferences.set("a", "1");
+ assertEquals("1", Preferences.get("a"));
+ assertEquals(List.of("a"), events);
+ }
+}
diff --git a/java/preprocessor/src/main/java/processing/app/Preferences.java b/java/preprocessor/src/main/java/processing/app/Preferences.java
index eab3a23974..6c30f2946c 100644
--- a/java/preprocessor/src/main/java/processing/app/Preferences.java
+++ b/java/preprocessor/src/main/java/processing/app/Preferences.java
@@ -21,47 +21,44 @@
package processing.app;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.util.Properties;
+import java.io.IOException;
+
/**
- * Storage class for user preferences and environment settings.
- *
- * This class does not use the Properties class because .properties files use
- * ISO 8859-1 encoding, which is highly likely to be a problem when trying to
- * save sketch folders and locations. Like the rest of Processing, we use UTF8.
- *
- * We don't use the Java Preferences API because it would entail writing to
- * the registry (on Windows), or an obscure file location (on Mac OS X) and
- * make it far more difficult (impossible) to remove the preferences.txt to
- * reset them (when they become corrupt), or to find the the file to make
- * edits for numerous obscure preferences that are not part of the preferences
- * window. If we added a generic editor (e.g. about:config in Mozilla) for
- * such things, we could start using the Java Preferences API. But wow, that
- * sounds like a lot of work. Not unlike writing this paragraph.
+ * Minimal stand-in for the app's Preferences so the preprocessor can be
+ * used without the PDE. Delegates to the standalone
+ * {@link processing.utils.Preferences}, which bundles the default settings,
+ * so preferences resolve even when no preferences.txt exists yet.
*/
public class Preferences {
- static public String get(String attribute /*, String defaultValue */) {
- try {
- var settingsFile = Base.getSettingsFile("preferences.txt");
- var reader = new BufferedReader(new FileReader(settingsFile));
-
- var settings = new Properties();
- settings.load(reader);
- reader.close();
-
- return settings.getProperty(attribute);
- }catch (Exception e) {
- return null;
- }
- }
- static public boolean getBoolean(String attribute) {
- String value = get(attribute);
- return Boolean.parseBoolean(value);
- }
- static public int getInteger(String attribute /*, int defaultValue*/) {
- return Integer.parseInt(get(attribute));
+
+ static private void ensureInitialized() {
+ if (!processing.utils.Preferences.isInitialized()) {
+ try {
+ processing.utils.Preferences.init();
+ } catch (IOException e) {
+ // The bundled defaults are enough to preprocess with; an unreadable
+ // preferences.txt shouldn't stop a build.
+ System.err.println("Could not read preferences: " + e.getMessage());
+ }
}
+ }
+
+
+ static public String get(String attribute) {
+ ensureInitialized();
+ return processing.utils.Preferences.get(attribute);
+ }
+
+
+ static public boolean getBoolean(String attribute) {
+ ensureInitialized();
+ return processing.utils.Preferences.getBoolean(attribute);
+ }
+
+
+ static public int getInteger(String attribute) {
+ ensureInitialized();
+ return processing.utils.Preferences.getInteger(attribute);
+ }
}