From 75a3442f9ff38222606a1e24753d4a57da1e8c0a Mon Sep 17 00:00:00 2001 From: 2xsaiko Date: Tue, 4 Aug 2020 20:42:39 +0200 Subject: Configuration stuff (#301) * Begin writing new config system * Make config work * Save window size and position * Add editor font chooser * Use *.ini for windows and mac instead of *rc * Allow for changing language without having to restart the program * Save selected directory in file dialogs * Make dialog visible after moving it to the correct position * Don't change theme on the fly since it's broken * Remove unused gui parameter * Use xdg-open to open URLs on Linux since Desktop.browse doesn't work, at least not on my PC * Fix default proposed highlight color * Multi font selection dialog thingy * Remember network options * Make font selection dialog actually work * Collapse general actions ("OK", "Cancel", ..) into one translation * Localize font dialog * Use enum name when saving colors for consistency with currently selected theme * Save size of split panes * Import old config * Add test & fix some parts of the config serializer * TranslationChangeListener/TranslationUtil -> LanguageChangeListener/LanguageUtil--- .../java/cuchaz/enigma/config/ConfigContainer.java | 97 +++++++ .../java/cuchaz/enigma/config/ConfigPaths.java | 40 +++ .../java/cuchaz/enigma/config/ConfigSection.java | 183 +++++++++++++ .../cuchaz/enigma/config/ConfigSerializer.java | 303 +++++++++++++++++++++ .../enigma/config/ConfigStructureVisitor.java | 11 + enigma/src/main/java/cuchaz/enigma/utils/Os.java | 33 +++ 6 files changed, 667 insertions(+) create mode 100644 enigma/src/main/java/cuchaz/enigma/config/ConfigContainer.java create mode 100644 enigma/src/main/java/cuchaz/enigma/config/ConfigPaths.java create mode 100644 enigma/src/main/java/cuchaz/enigma/config/ConfigSection.java create mode 100644 enigma/src/main/java/cuchaz/enigma/config/ConfigSerializer.java create mode 100644 enigma/src/main/java/cuchaz/enigma/config/ConfigStructureVisitor.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/Os.java (limited to 'enigma/src/main/java') diff --git a/enigma/src/main/java/cuchaz/enigma/config/ConfigContainer.java b/enigma/src/main/java/cuchaz/enigma/config/ConfigContainer.java new file mode 100644 index 0000000..cb9cbc2 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/config/ConfigContainer.java @@ -0,0 +1,97 @@ +package cuchaz.enigma.config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Deque; +import java.util.LinkedList; + +public class ConfigContainer { + + private Path configPath; + private boolean existsOnDisk; + + private final ConfigSection root = new ConfigSection(); + + public ConfigSection data() { + return this.root; + } + + public void save() { + if (this.configPath == null) throw new IllegalStateException("File has no config path set!"); + try { + Files.createDirectories(this.configPath.getParent()); + Files.write(this.configPath, this.serialize().getBytes(StandardCharsets.UTF_8)); + this.existsOnDisk = true; + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void saveAs(Path path) { + this.configPath = path; + this.save(); + } + + public boolean existsOnDisk() { + return this.existsOnDisk; + } + + public String serialize() { + return ConfigSerializer.structureToString(this.root); + } + + public static ConfigContainer create() { + return new ConfigContainer(); + } + + public static ConfigContainer getOrCreate(String name) { + return ConfigContainer.getOrCreate(ConfigPaths.getConfigFilePath(name)); + } + + public static ConfigContainer getOrCreate(Path path) { + ConfigContainer cc = null; + try { + if (Files.exists(path)) { + String s = String.join("\n", Files.readAllLines(path)); + cc = ConfigContainer.parse(s); + cc.existsOnDisk = true; + } + } catch (IOException e) { + e.printStackTrace(); + } + + if (cc == null) { + cc = ConfigContainer.create(); + } + + cc.configPath = path; + return cc; + } + + public static ConfigContainer parse(String source) { + ConfigContainer cc = ConfigContainer.create(); + Deque stack = new LinkedList<>(); + stack.push(cc.root); + ConfigSerializer.parse(source, new ConfigStructureVisitor() { + @Override + public void visitKeyValue(String key, String value) { + stack.peekLast().setString(key, value); + } + + @Override + public void visitSection(String section) { + stack.add(stack.peekLast().section(section)); + } + + @Override + public void jumpToRootSection() { + stack.clear(); + stack.push(cc.root); + } + }); + return cc; + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/config/ConfigPaths.java b/enigma/src/main/java/cuchaz/enigma/config/ConfigPaths.java new file mode 100644 index 0000000..6e668f8 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/config/ConfigPaths.java @@ -0,0 +1,40 @@ +package cuchaz.enigma.config; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import cuchaz.enigma.utils.Os; + +public class ConfigPaths { + + public static Path getConfigFilePath(String name) { + String fileName = Os.getOs() == Os.LINUX ? String.format("%src", name) : String.format("%s.ini", name); + return getConfigPathRoot().resolve(fileName); + } + + public static Path getConfigPathRoot() { + switch (Os.getOs()) { + case LINUX: + String configHome = System.getenv("XDG_CONFIG_HOME"); + if (configHome == null) { + return getUserHomeUnix().resolve(".config"); + } + return Paths.get(configHome); + case MAC: + return getUserHomeUnix().resolve("Library").resolve("Preferences"); + case WINDOWS: + return Paths.get(System.getenv("LOCALAPPDATA")); + default: + return Paths.get(System.getProperty("user.dir")); + } + } + + private static Path getUserHomeUnix() { + String userHome = System.getenv("HOME"); + if (userHome == null) { + userHome = System.getProperty("user.dir"); + } + return Paths.get(userHome); + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/config/ConfigSection.java b/enigma/src/main/java/cuchaz/enigma/config/ConfigSection.java new file mode 100644 index 0000000..3e7bf6d --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/config/ConfigSection.java @@ -0,0 +1,183 @@ +package cuchaz.enigma.config; + +import java.util.*; +import java.util.function.Function; + +public class ConfigSection { + + private final Map values; + private final Map sections; + + private ConfigSection(Map values, Map sections) { + this.values = values; + this.sections = sections; + } + + public ConfigSection() { + this(new HashMap<>(), new HashMap<>()); + } + + public ConfigSection section(String name) { + return this.sections.computeIfAbsent(name, _s -> new ConfigSection()); + } + + public Map values() { + return Collections.unmodifiableMap(this.values); + } + + public Map sections() { + return Collections.unmodifiableMap(this.sections); + } + + public boolean remove(String key) { + return this.values.remove(key) != null; + } + + public boolean removeSection(String key) { + return this.sections.remove(key) != null; + } + + public Optional getString(String key) { + return Optional.ofNullable(this.values.get(key)); + } + + public void setString(String key, String value) { + this.values.put(key, value); + } + + public String setIfAbsentString(String key, String value) { + this.values.putIfAbsent(key, value); + return this.values.get(key); + } + + public Optional getBool(String key) { + return ConfigSerializer.parseBool(this.values.get(key)); + } + + public boolean setIfAbsentBool(String key, boolean value) { + return this.getBool(key).orElseGet(() -> { + this.setBool(key, value); + return value; + }); + } + + public void setBool(String key, boolean value) { + this.values.put(key, Boolean.toString(value)); + } + + public OptionalInt getInt(String key) { + return ConfigSerializer.parseInt(this.values.get(key)); + } + + public void setInt(String key, int value) { + this.values.put(key, Integer.toString(value)); + } + + public int setIfAbsentInt(String key, int value) { + return this.getInt(key).orElseGet(() -> { + this.setInt(key, value); + return value; + }); + } + + public OptionalDouble getDouble(String key) { + return ConfigSerializer.parseDouble(this.values.get(key)); + } + + public void setDouble(String key, double value) { + this.values.put(key, Double.toString(value)); + } + + public double setIfAbsentDouble(String key, double value) { + return this.getDouble(key).orElseGet(() -> { + this.setDouble(key, value); + return value; + }); + } + + public OptionalInt getRgbColor(String key) { + return ConfigSerializer.parseRgbColor(this.values.get(key)); + } + + public void setRgbColor(String key, int value) { + this.values.put(key, ConfigSerializer.rgbColorToString(value)); + } + + public int setIfAbsentRgbColor(String key, int value) { + return this.getRgbColor(key).orElseGet(() -> { + this.setRgbColor(key, value); + return value; + }); + } + + public Optional getArray(String key) { + return ConfigSerializer.parseArray(this.values.get(key)); + } + + public void setArray(String key, String[] value) { + this.values.put(key, ConfigSerializer.arrayToString(value)); + } + + public String[] setIfAbsentArray(String key, String[] value) { + return this.getArray(key).orElseGet(() -> { + this.setArray(key, value); + return value; + }); + } + + public Optional getIntArray(String key) { + return this.getArray(key).map(arr -> Arrays.stream(arr).mapToInt(s -> ConfigSerializer.parseInt(s).orElse(0)).toArray()); + } + + public void setIntArray(String key, int[] value) { + this.setArray(key, Arrays.stream(value).mapToObj(Integer::toString).toArray(String[]::new)); + } + + public int[] setIfAbsentIntArray(String key, int[] value) { + return this.getIntArray(key).orElseGet(() -> { + this.setIntArray(key, value); + return value; + }); + } + + public > Optional getEnum(Function byName, String key) { + return ConfigSerializer.parseEnum(byName, this.values.get(key)); + } + + public > void setEnum(String key, T value) { + this.values.put(key, value.name()); + } + + public > T setIfAbsentEnum(Function byName, String key, T value) { + return this.getEnum(byName, key).orElseGet(() -> { + this.setEnum(key, value); + return value; + }); + } + + public ConfigSection copy() { + Map sections = new HashMap<>(this.sections); + sections.replaceAll((k, v) -> v.copy()); + return new ConfigSection(new HashMap<>(this.values), sections); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ConfigSection)) return false; + ConfigSection that = (ConfigSection) o; + return values.equals(that.values) && + sections.equals(that.sections); + } + + @Override + public int hashCode() { + return Objects.hash(values, sections); + } + + @Override + public String toString() { + return String.format("ConfigSection { values: %s, sections: %s }", values, sections); + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/config/ConfigSerializer.java b/enigma/src/main/java/cuchaz/enigma/config/ConfigSerializer.java new file mode 100644 index 0000000..dccb585 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/config/ConfigSerializer.java @@ -0,0 +1,303 @@ +package cuchaz.enigma.config; + +import java.util.*; +import java.util.Map.Entry; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class ConfigSerializer { + + private static final Pattern FULL_RGB_COLOR = Pattern.compile("#[0-9A-Fa-f]{6}"); + private static final Pattern MIN_RGB_COLOR = Pattern.compile("#[0-9A-Fa-f]{3}"); + + private static final int UNEXPECTED_TOKEN = -1; + private static final int NO_MATCH = -2; + + public static void parse(String v, ConfigStructureVisitor visitor) { + String[] lines = v.split("\n"); + + // join escaped newlines + int len = lines.length; + for (int i = len - 2; i >= 0; i--) { + if (lines[i].endsWith("\\")) { + lines[i] = String.format("%s\n%s", lines[i], lines[i + 1]); + len -= 1; + } + } + + // parse for real + for (int i = 0; i < len; i++) { + String line = lines[i]; + + // skip empty lines and comment lines + if (line.trim().isEmpty() || line.trim().startsWith(";")) continue; + + int r; + boolean fail = (r = parseSectionLine(line, 0, visitor)) == NO_MATCH && + (r = parseKeyValue(line, 0, visitor)) == NO_MATCH; + } + } + + private static int parseSectionLine(String v, int idx, ConfigStructureVisitor visitor) { + if (v.startsWith("[")) { + List path = new ArrayList<>(); + while (idx < v.length() && v.charAt(idx) == '[') { + idx = parseSection(v, idx, path); + if (idx == UNEXPECTED_TOKEN) return UNEXPECTED_TOKEN; + } + + if (!path.isEmpty()) { + visitor.jumpToRootSection(); + for (String s : path) { + visitor.visitSection(s); + } + } + + return v.length(); + } else { + return NO_MATCH; + } + } + + private static int parseSection(String v, int idx, List path) { + idx += 1; // skip leading [ + StringBuilder sb = new StringBuilder(); + while (idx < v.length()) { + int nextCloseBracket = v.indexOf(']', idx); + int nextEscape = v.indexOf('\\', idx); + int next = optMin(nextCloseBracket, nextEscape); + if (next == -1) { + // unexpected + return UNEXPECTED_TOKEN; + } else if (next == nextCloseBracket) { + sb.append(v, idx, nextCloseBracket); + path.add(sb.toString()); + return nextCloseBracket + 1; + } else if (next == nextEscape) { + sb.append(v, idx, nextEscape); + idx = parseEscape(v, nextEscape, sb); + } + } + return idx; + } + + private static int parseKeyValue(String v, int idx, ConfigStructureVisitor visitor) { + StringBuilder sb = new StringBuilder(); + String k = null; + while (idx < v.length()) { + int nextEq = v.indexOf('=', idx); + int nextEscape = v.indexOf('\\', idx); + int next = optMin(nextEq, nextEscape); + if (next == -1) { + break; + } else if (next == nextEq) { + sb.append(v, idx, nextEq); + k = sb.toString(); + sb.delete(0, sb.length()); + idx = nextEq + 1; + break; + } else if (next == nextEscape) { + sb.append(v, idx, nextEscape); + idx = parseEscape(v, nextEscape, sb); + } + } + while (idx < v.length()) { + int nextEscape = v.indexOf('\\', idx); + if (nextEscape != -1) { + sb.append(v, idx, nextEscape); + idx = parseEscape(v, nextEscape, sb); + } else { + break; + } + } + sb.append(v, idx, v.length()); + if (k == null) return NO_MATCH; + visitor.visitKeyValue(k, sb.toString()); + return idx; + } + + private static int parseEscape(String v, int idx, StringBuilder sb) { + if (idx + 1 < v.length()) { + if (v.charAt(idx + 1) == 'u') { + if (idx + 5 < v.length()) { + String codePoint = v.substring(idx + 2, idx + 6); + try { + int c = Integer.parseUnsignedInt(codePoint, 16); + sb.append((char) c); + } catch (NumberFormatException ignored) { + } + idx = idx + 6; + } + } else if (v.charAt(idx + 1) == 'n') { + sb.append('\n'); + idx = idx + 2; + } else { + sb.append(v.charAt(idx + 1)); + idx = idx + 2; + } + } else { + idx = idx + 1; + } + return idx; + } + + public static String structureToString(ConfigSection section) { + StringBuilder sb = new StringBuilder(); + structureToString(section, sb, new ArrayList<>()); + return sb.toString(); + } + + private static void structureToString(ConfigSection section, StringBuilder sb, List pathStack) { + if (!section.values().isEmpty()) { + if (sb.length() > 0) sb.append('\n'); + pathStack.forEach(n -> sb.append('[').append(escapeSection(n)).append(']')); + if (!pathStack.isEmpty()) sb.append('\n'); + section.values().entrySet().stream() + .sorted(Entry.comparingByKey()) + .forEach(e -> sb.append(escapeKey(e.getKey())).append('=').append(escapeValue(e.getValue())).append('\n')); + } + + section.sections().entrySet().stream().sorted(Entry.comparingByKey()).forEach(e -> { + pathStack.add(e.getKey()); + structureToString(e.getValue(), sb, pathStack); + pathStack.remove(pathStack.size() - 1); + }); + } + + private static String escapeSection(String s) { + return s + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("]", "\\]") + .chars().mapToObj(c -> c >= 32 && c < 127 ? Character.toString((char) c) : String.format("\\u%04x", c)).collect(Collectors.joining()); + } + + private static String escapeKey(String s) { + return s + .replace("\\", "\\\\") + .replace("[", "\\[") + .replace("\n", "\\n") + .replace("=", "\\=") + .chars().mapToObj(c -> c >= 32 && c < 127 ? Character.toString((char) c) : String.format("\\u%04x", c)).collect(Collectors.joining()); + } + + private static String escapeValue(String s) { + return s + .replace("\\", "\\\\") + .replace("\n", "\\n") + .chars().mapToObj(c -> c >= 32 && c < 127 ? Character.toString((char) c) : String.format("\\u%04x", c)).collect(Collectors.joining()); + } + + public static Optional parseBool(String v) { + if (v == null) return Optional.empty(); + switch (v) { + case "true": + return Optional.of(true); + case "false": + return Optional.of(false); + default: + return Optional.empty(); + } + } + + public static OptionalInt parseInt(String v) { + if (v == null) return OptionalInt.empty(); + try { + return OptionalInt.of(Integer.parseInt(v)); + } catch (NumberFormatException e) { + return OptionalInt.empty(); + } + } + + public static OptionalDouble parseDouble(String v) { + if (v == null) return OptionalDouble.empty(); + try { + return OptionalDouble.of(Double.parseDouble(v)); + } catch (NumberFormatException e) { + return OptionalDouble.empty(); + } + } + + public static OptionalInt parseRgbColor(String v) { + if (v == null) return OptionalInt.empty(); + try { + if (FULL_RGB_COLOR.matcher(v).matches()) { + return OptionalInt.of(Integer.parseUnsignedInt(v.substring(1), 16)); + } else if (MIN_RGB_COLOR.matcher(v).matches()) { + int result = Integer.parseUnsignedInt(v.substring(1), 16); + // change 0xABC to 0xAABBCC + result = (result & 0x00F) | (result & 0x0F0) << 4 | (result & 0xF00) << 8; + result = result | result << 4; + return OptionalInt.of(result); + } else { + return OptionalInt.empty(); + } + } catch (NumberFormatException e) { + return OptionalInt.empty(); + } + } + + public static String rgbColorToString(int color) { + color = color & 0xFFFFFF; + boolean isShort = ((color & 0xF0F0F0) >> 4 ^ color & 0x0F0F0F) == 0; + if (isShort) { + int packed = color & 0x0F0F0F; + packed = packed & 0xF | packed >> 4; + packed = packed & 0xFF | (packed & ~0xFF) >> 4; + return String.format("#%03x", packed); + } else { + return String.format("#%06x", color); + } + } + + public static Optional parseArray(String v) { + if (v == null) return Optional.empty(); + List l = new ArrayList<>(); + int idx = 0; + StringBuilder cur = new StringBuilder(); + while (true) { + int nextSep = v.indexOf(',', idx); + int nextEsc = v.indexOf('\\', idx); + int next = optMin(nextSep, nextEsc); + if (next == -1) { + cur.append(v, idx, v.length()); + l.add(cur.toString()); + return Optional.of(l.toArray(new String[0])); + } else if (next == nextSep) { + cur.append(v, idx, nextSep); + l.add(cur.toString()); + cur.delete(0, cur.length()); + idx = nextSep + 1; + } else if (next == nextEsc) { + cur.append(v, idx, nextEsc); + if (nextEsc + 1 < v.length()) { + cur.append(v.charAt(nextEsc + 1)); + } + idx = nextEsc + 2; + } + } + } + + public static String arrayToString(String[] values) { + return Arrays.stream(values) + .map(s -> s.replace("\\", "\\\\").replace(",", "\\,")) + .collect(Collectors.joining(",")); + } + + public static > Optional parseEnum(Function byName, String v) { + if (v == null) return Optional.empty(); + try { + return Optional.of(byName.apply(v)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private static int optMin(int v1, int v2) { + if (v1 == -1) return v2; + if (v2 == -1) return v1; + return Math.min(v1, v2); + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/config/ConfigStructureVisitor.java b/enigma/src/main/java/cuchaz/enigma/config/ConfigStructureVisitor.java new file mode 100644 index 0000000..12d7ec4 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/config/ConfigStructureVisitor.java @@ -0,0 +1,11 @@ +package cuchaz.enigma.config; + +public interface ConfigStructureVisitor { + + void visitKeyValue(String key, String value); + + void visitSection(String section); + + void jumpToRootSection(); + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/Os.java b/enigma/src/main/java/cuchaz/enigma/utils/Os.java new file mode 100644 index 0000000..b493c04 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/Os.java @@ -0,0 +1,33 @@ +package cuchaz.enigma.utils; + +import java.util.Locale; + +public enum Os { + LINUX, + MAC, + SOLARIS, + WINDOWS, + OTHER; + + private static Os os = null; + + public static Os getOs() { + if (os == null) { + String osName = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (osName.contains("mac") || osName.contains("darwin")) { + os = MAC; + } else if (osName.contains("win")) { + os = WINDOWS; + } else if (osName.contains("nix") || osName.contains("nux") + || osName.contains("aix")) { + os = LINUX; + } else if (osName.contains("sunos")) { + os = SOLARIS; + } else { + os = OTHER; + } + } + return os; + } + +} \ No newline at end of file -- cgit v1.2.3