Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git

Add dynamic metadata, precursor to command-line operations

AuthorDaveJarvis <email>
Date2021-12-27 13:02:01 GMT-0800
Commitd14acf8214b5f1b0602bfc0221674d91a5d153e3
Parent97661c4
Delta1495 lines added, 1262 lines removed, 233-line increase
src/test/java/com/keenwrite/tex/TeXRasterizationTest.java
final var expectedSvg = g.toString();
final var bytes = expectedSvg.getBytes();
- final var doc = parse( new ByteArrayInputStream( bytes ) );
- final var actualSvg = toSvg( doc.getDocumentElement() );
- verifyImage( rasterizeString( actualSvg ) );
+ try( final var in = new ByteArrayInputStream( bytes ) ) {
+ final var doc = parse( in );
+ final var actualSvg = toSvg( doc.getDocumentElement() );
+
+ verifyImage( rasterizeString( actualSvg ) );
+ }
}
src/main/java/com/keenwrite/preferences/Key.java
package com.keenwrite.preferences;
+import java.util.Stack;
+import java.util.function.Consumer;
+
/**
* Responsible for creating a type hierarchy of preference storage keys.
*/
public class Key {
private final Key mParent;
private final String mName;
-
- private Key( final Key parent, final String name ) {
- mParent = parent;
- mName = name;
- }
/**
* Returns a new key with no parent.
*
* @param name The key name, never {@code null}.
* @return The new {@link Key} instance with a name but no parent.
*/
public static Key key( final String name ) {
- assert name != null && !name.isEmpty();
return key( null, name );
}
*/
public static Key key( final Key parent, final String name ) {
- assert name != null && !name.isEmpty();
return new Key( parent, name );
}
- private Key parent() {
+ private Key( final Key parent, final String name ) {
+ assert name != null;
+ assert !name.isBlank();
+
+ mParent = parent;
+ mName = name;
+ }
+
+ /**
+ * Answers whether more {@link Key}s exist above this one in the hierarchy.
+ *
+ * @return {@code true} means this {@link Key} has a parent {@link Key}.
+ */
+ public boolean hasParent() {
+ return mParent != null;
+ }
+
+ /**
+ * Visits every key in the hierarchy, starting at the topmost {@link Key} and
+ * ending with the current {@link Key}.
+ *
+ * @param consumer Receives the name of each visited node.
+ * @param separator Characters to insert between each node.
+ */
+ public void walk( final Consumer<String> consumer, final String separator ) {
+ var key = this;
+
+ final var stack = new Stack<String>();
+
+ while( key != null ) {
+ stack.push( key.name() );
+ key = key.parent();
+ }
+
+ var sep = "";
+
+ while( !stack.empty() ) {
+ consumer.accept( sep + stack.pop() );
+ sep = separator;
+ }
+ }
+
+ public void walk( final Consumer<String> consumer ) {
+ walk( consumer, "" );
+ }
+
+ public Key parent() {
return mParent;
}
- private String name() {
+ public String name() {
return mName;
}
/**
* Returns a dot-separated path representing the key's name.
*
- * @return The recursively derived dot-separated key name.
+ * @return The dot-separated key name.
*/
@Override
public String toString() {
- return parent() == null ? name() : parent().toString() + '.' + name();
+ final var sb = new StringBuilder( 128 );
+
+ walk( sb::append, "." );
+
+ return sb.toString();
}
}
src/main/java/com/keenwrite/preferences/PreferencesController.java
import com.dlsc.formsfx.model.structure.StringField;
import com.dlsc.preferencesfx.PreferencesFx;
-import com.dlsc.preferencesfx.PreferencesFxEvent;
-import com.dlsc.preferencesfx.model.Category;
-import com.dlsc.preferencesfx.model.Group;
-import com.dlsc.preferencesfx.model.Setting;
-import com.dlsc.preferencesfx.util.StorageHandler;
-import com.dlsc.preferencesfx.view.NavigationView;
-import javafx.beans.property.*;
-import javafx.event.EventHandler;
-import javafx.scene.Node;
-import javafx.scene.control.Button;
-import javafx.scene.control.DialogPane;
-import javafx.scene.control.Label;
-import org.controlsfx.control.MasterDetailPane;
-
-import java.io.File;
-import java.util.Map.Entry;
-
-import static com.dlsc.formsfx.model.structure.Field.ofStringType;
-import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
-import static com.keenwrite.preferences.AppKeys.*;
-import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
-import static com.keenwrite.preferences.SkinProperty.skinListProperty;
-import static com.keenwrite.preferences.TableField.ofListType;
-import static javafx.scene.control.ButtonType.CANCEL;
-import static javafx.scene.control.ButtonType.OK;
-
-/**
- * Provides the ability for users to configure their preferences. This links
- * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
- */
-@SuppressWarnings( "SameParameterValue" )
-public final class PreferencesController {
-
- private final Workspace mWorkspace;
- private final PreferencesFx mPreferencesFx;
-
- public PreferencesController( final Workspace workspace ) {
- mWorkspace = workspace;
-
- // All properties must be initialized before creating the dialog.
- mPreferencesFx = createPreferencesFx();
-
- initKeyEventHandler( mPreferencesFx );
- }
-
- /**
- * Display the user preferences settings dialog (non-modal).
- */
- public void show() {
- getPreferencesFx().show( false );
- }
-
- /**
- * Call to persist the settings. Strictly speaking, this could watch on
- * all values for external changes then save automatically.
- */
- public void save() {
- getPreferencesFx().saveSettings();
- }
-
- /**
- * Delegates to the {@link PreferencesFx} event handler for monitoring
- * save events.
- *
- * @param eventHandler The handler to call when the preferences are saved.
- */
- public void addSaveEventHandler(
- final EventHandler<? super PreferencesFxEvent> eventHandler ) {
- getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
- }
-
- private StringField createFontNameField(
- final StringProperty fontName, final DoubleProperty fontSize ) {
- final var control = new SimpleFontControl( "Change" );
- control.fontSizeProperty().addListener( ( c, o, n ) -> {
- if( n != null ) {
- fontSize.set( n.doubleValue() );
- }
- } );
- return ofStringType( fontName ).render( control );
- }
-
- /**
- * Convenience method to create a helper class for the user interface. This
- * establishes a key-value pair for the view.
- *
- * @param persist A reference to the values that will be persisted.
- * @param <K> The type of key, usually a string.
- * @param <V> The type of value, usually a string.
- * @return UI data model container that may update the persistent state.
- */
- private <K, V> TableField<Entry<K, V>> createTableField(
- final ListProperty<Entry<K, V>> persist ) {
- return ofListType( persist ).render( new SimpleTableControl<>() );
- }
-
- /**
- * Creates the preferences dialog based using {@link XmlStorageHandler} and
- * numerous {@link Category} objects.
- *
- * @return A component for editing preferences.
- * @throws RuntimeException Could not construct the {@link PreferencesFx}
- * object (e.g., illegal access permissions,
- * unmapped XML resource).
- */
- private PreferencesFx createPreferencesFx() {
- return PreferencesFx.of( createStorageHandler(), createCategories() )
- .instantPersistent( false )
- .dialogIcon( ICON_DIALOG );
- }
-
- private StorageHandler createStorageHandler() {
- return new XmlStorageHandler();
- }
-
- private Category[] createCategories() {
- return new Category[]{
- Category.of(
- get( KEY_DOC ),
- Group.of(
- get( KEY_DOC_META ),
- Setting.of( label( KEY_DOC_META ) ),
- Setting.of( title( KEY_DOC_META ),
- createTableField( listEntryProperty( KEY_DOC_META ) ),
- listEntryProperty( KEY_DOC_META ) )
- ),
- Group.of(
- get( KEY_DOC_TITLE ),
- Setting.of( label( KEY_DOC_TITLE ) ),
- Setting.of( title( KEY_DOC_TITLE ),
- stringProperty( KEY_DOC_TITLE ) )
- ),
- Group.of(
- get( KEY_DOC_AUTHOR ),
- Setting.of( label( KEY_DOC_AUTHOR ) ),
- Setting.of( title( KEY_DOC_AUTHOR ),
- stringProperty( KEY_DOC_AUTHOR ) )
- ),
- Group.of(
- get( KEY_DOC_BYLINE ),
- Setting.of( label( KEY_DOC_BYLINE ) ),
- Setting.of( title( KEY_DOC_BYLINE ),
- stringProperty( KEY_DOC_BYLINE ) )
- ),
- Group.of(
- get( KEY_DOC_ADDRESS ),
- Setting.of( label( KEY_DOC_ADDRESS ) ),
- createMultilineSetting( "Address", KEY_DOC_ADDRESS )
- ),
- Group.of(
- get( KEY_DOC_PHONE ),
- Setting.of( label( KEY_DOC_PHONE ) ),
- Setting.of( title( KEY_DOC_PHONE ),
- stringProperty( KEY_DOC_PHONE ) )
- ),
- Group.of(
- get( KEY_DOC_EMAIL ),
- Setting.of( label( KEY_DOC_EMAIL ) ),
- Setting.of( title( KEY_DOC_EMAIL ),
- stringProperty( KEY_DOC_EMAIL ) )
- ),
- Group.of(
- get( KEY_DOC_KEYWORDS ),
- Setting.of( label( KEY_DOC_KEYWORDS ) ),
- Setting.of( title( KEY_DOC_KEYWORDS ),
- stringProperty( KEY_DOC_KEYWORDS ) )
- ),
- Group.of(
- get( KEY_DOC_COPYRIGHT ),
- Setting.of( label( KEY_DOC_COPYRIGHT ) ),
- Setting.of( title( KEY_DOC_COPYRIGHT ),
- stringProperty( KEY_DOC_COPYRIGHT ) )
- ),
- Group.of(
- get( KEY_DOC_DATE ),
- Setting.of( label( KEY_DOC_DATE ) ),
- Setting.of( title( KEY_DOC_DATE ),
- stringProperty( KEY_DOC_DATE ) )
- )
- ),
- Category.of(
- get( KEY_TYPESET ),
- Group.of(
- get( KEY_TYPESET_CONTEXT ),
- Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
- Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
- fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
- Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
- Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
- booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
- ),
- Group.of(
- get( KEY_TYPESET_TYPOGRAPHY ),
- Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
- Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
- booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
- )
- ),
- Category.of(
- get( KEY_EDITOR ),
- Group.of(
- get( KEY_EDITOR_AUTOSAVE ),
- Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
- Setting.of( title( KEY_EDITOR_AUTOSAVE ),
- integerProperty( KEY_EDITOR_AUTOSAVE ) )
- )
- ),
- Category.of(
- get( KEY_R ),
- Group.of(
- get( KEY_R_DIR ),
- Setting.of( label( KEY_R_DIR,
- stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
- stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
- Setting.of( title( KEY_R_DIR ),
- fileProperty( KEY_R_DIR ), true )
- ),
- Group.of(
- get( KEY_R_SCRIPT ),
- Setting.of( label( KEY_R_SCRIPT ) ),
- createMultilineSetting( "Script", KEY_R_SCRIPT )
- ),
- Group.of(
- get( KEY_R_DELIM_BEGAN ),
- Setting.of( label( KEY_R_DELIM_BEGAN ) ),
- Setting.of( title( KEY_R_DELIM_BEGAN ),
- stringProperty( KEY_R_DELIM_BEGAN ) )
- ),
- Group.of(
- get( KEY_R_DELIM_ENDED ),
- Setting.of( label( KEY_R_DELIM_ENDED ) ),
- Setting.of( title( KEY_R_DELIM_ENDED ),
- stringProperty( KEY_R_DELIM_ENDED ) )
- )
- ),
- Category.of(
- get( KEY_IMAGES ),
- Group.of(
- get( KEY_IMAGES_DIR ),
- Setting.of( label( KEY_IMAGES_DIR ) ),
- Setting.of( title( KEY_IMAGES_DIR ),
- fileProperty( KEY_IMAGES_DIR ), true )
- ),
- Group.of(
- get( KEY_IMAGES_ORDER ),
- Setting.of( label( KEY_IMAGES_ORDER ) ),
- Setting.of( title( KEY_IMAGES_ORDER ),
- stringProperty( KEY_IMAGES_ORDER ) )
- ),
- Group.of(
- get( KEY_IMAGES_RESIZE ),
- Setting.of( label( KEY_IMAGES_RESIZE ) ),
- Setting.of( title( KEY_IMAGES_RESIZE ),
- booleanProperty( KEY_IMAGES_RESIZE ) )
- ),
- Group.of(
- get( KEY_IMAGES_SERVER ),
- Setting.of( label( KEY_IMAGES_SERVER ) ),
- Setting.of( title( KEY_IMAGES_SERVER ),
- stringProperty( KEY_IMAGES_SERVER ) )
- )
- ),
- Category.of(
- get( KEY_DEF ),
- Group.of(
- get( KEY_DEF_PATH ),
- Setting.of( label( KEY_DEF_PATH ) ),
- Setting.of( title( KEY_DEF_PATH ),
- fileProperty( KEY_DEF_PATH ), false )
- ),
- Group.of(
- get( KEY_DEF_DELIM_BEGAN ),
- Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
- Setting.of( title( KEY_DEF_DELIM_BEGAN ),
- stringProperty( KEY_DEF_DELIM_BEGAN ) )
- ),
- Group.of(
- get( KEY_DEF_DELIM_ENDED ),
- Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
- Setting.of( title( KEY_DEF_DELIM_ENDED ),
- stringProperty( KEY_DEF_DELIM_ENDED ) )
- )
- ),
- Category.of(
- get( KEY_UI_FONT ),
- Group.of(
- get( KEY_UI_FONT_EDITOR ),
- Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
- Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
- createFontNameField(
- stringProperty( KEY_UI_FONT_EDITOR_NAME ),
- doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
- stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
- Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
- Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
- doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
- ),
- Group.of(
- get( KEY_UI_FONT_PREVIEW ),
- Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
- Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
- createFontNameField(
- stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
- doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
- stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
- Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
- Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
- doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
- Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
- Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
- createFontNameField(
- stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
- doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
- stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
- Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
- Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
- doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
- )
- ),
- Category.of(
- get( KEY_UI_SKIN ),
- Group.of(
- get( KEY_UI_SKIN_SELECTION ),
- Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
- Setting.of( title( KEY_UI_SKIN_SELECTION ),
- skinListProperty(),
- skinProperty( KEY_UI_SKIN_SELECTION ) )
- ),
- Group.of(
- get( KEY_UI_SKIN_CUSTOM ),
- Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
- Setting.of( title( KEY_UI_SKIN_CUSTOM ),
- fileProperty( KEY_UI_SKIN_CUSTOM ), false )
- )
- ),
- Category.of(
- get( KEY_UI_PREVIEW ),
- Group.of(
- get( KEY_UI_PREVIEW_STYLESHEET ),
- Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
- Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
- fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
- )
- ),
- Category.of(
- get( KEY_LANGUAGE ),
- Group.of(
- get( KEY_LANGUAGE_LOCALE ),
- Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
- Setting.of( title( KEY_LANGUAGE_LOCALE ),
- localeListProperty(),
- localeProperty( KEY_LANGUAGE_LOCALE ) )
- )
- )};
- }
-
- @SuppressWarnings( "unchecked" )
- private Setting<StringField, StringProperty> createMultilineSetting(
- final String description, final Key property ) {
- final Setting<StringField, StringProperty> setting =
- Setting.of( description, stringProperty( property ) );
- final var field = setting.getElement();
- field.multiline( true );
-
- return setting;
- }
-
- /**
- * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively.
- */
- private void initKeyEventHandler( final PreferencesFx preferences ) {
- final var view = preferences.getView();
- final var nodes = view.getChildrenUnmodifiable();
- final var master = (MasterDetailPane) nodes.get( 0 );
- final var detail = (NavigationView) master.getDetailNode();
- final var pane = (DialogPane) view.getParent();
-
- detail.setOnKeyReleased( key -> {
- switch( key.getCode() ) {
- case ENTER -> ((Button) pane.lookupButton( OK )).fire();
- case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
- }
- } );
- }
-
- /**
- * Creates a label for the given key after interpolating its value.
- *
- * @param key The key to find in the resource bundle.
- * @return The value of the key as a label.
- */
- private Node label( final Key key ) {
- return label( key, (String[]) null );
- }
-
- private Node label( final Key key, final String... values ) {
- return new Label( get( key.toString() + ".desc", (Object[]) values ) );
- }
-
- private String title( final Key key ) {
- return get( key.toString() + ".title" );
- }
-
- private ObjectProperty<File> fileProperty( final Key key ) {
- return mWorkspace.fileProperty( key );
- }
-
- private StringProperty stringProperty( final Key key ) {
- return mWorkspace.stringProperty( key );
- }
-
- private BooleanProperty booleanProperty( final Key key ) {
- return mWorkspace.booleanProperty( key );
- }
-
- @SuppressWarnings( "SameParameterValue" )
- private IntegerProperty integerProperty( final Key key ) {
- return mWorkspace.integerProperty( key );
- }
-
- @SuppressWarnings( "SameParameterValue" )
- private DoubleProperty doubleProperty( final Key key ) {
- return mWorkspace.doubleProperty( key );
- }
-
- private ObjectProperty<String> skinProperty( final Key key ) {
- return mWorkspace.skinProperty( key );
- }
-
- private ObjectProperty<String> localeProperty( final Key key ) {
- return mWorkspace.localeProperty( key );
- }
-
- private <E> ListProperty<E> listEntryProperty( final Key key ) {
- return mWorkspace.listsProperty( key );
- }
-
- private PreferencesFx getPreferencesFx() {
- return mPreferencesFx;
+import com.dlsc.preferencesfx.model.Category;
+import com.dlsc.preferencesfx.model.Group;
+import com.dlsc.preferencesfx.model.Setting;
+import com.dlsc.preferencesfx.util.StorageHandler;
+import com.dlsc.preferencesfx.view.NavigationView;
+import javafx.beans.property.*;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.DialogPane;
+import javafx.scene.control.Label;
+import org.controlsfx.control.MasterDetailPane;
+
+import java.io.File;
+import java.util.Map.Entry;
+
+import static com.dlsc.formsfx.model.structure.Field.ofStringType;
+import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
+import static com.keenwrite.preferences.AppKeys.*;
+import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
+import static com.keenwrite.preferences.SkinProperty.skinListProperty;
+import static com.keenwrite.preferences.TableField.ofListType;
+import static javafx.scene.control.ButtonType.CANCEL;
+import static javafx.scene.control.ButtonType.OK;
+
+/**
+ * Provides the ability for users to configure their preferences. This links
+ * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
+ */
+@SuppressWarnings( "SameParameterValue" )
+public final class PreferencesController {
+
+ private final Workspace mWorkspace;
+ private final PreferencesFx mPreferencesFx;
+
+ public PreferencesController( final Workspace workspace ) {
+ mWorkspace = workspace;
+
+ // Order matters: set the workspace before creating the dialog.
+ mPreferencesFx = createPreferencesFx();
+
+ initKeyEventHandler( mPreferencesFx );
+ initSaveEventHandler( mPreferencesFx );
+ }
+
+ /**
+ * Display the user preferences settings dialog (non-modal).
+ */
+ public void show() {
+ mPreferencesFx.show( false );
+ }
+
+ private StringField createFontNameField(
+ final StringProperty fontName, final DoubleProperty fontSize ) {
+ final var control = new SimpleFontControl( "Change" );
+
+ control.fontSizeProperty().addListener( ( c, o, n ) -> {
+ if( n != null ) {
+ fontSize.set( n.doubleValue() );
+ }
+ } );
+
+ return ofStringType( fontName ).render( control );
+ }
+
+ /**
+ * Convenience method to create a helper class for the user interface. This
+ * establishes a key-value pair for the view.
+ *
+ * @param persist A reference to the values that will be persisted.
+ * @param <K> The type of key, usually a string.
+ * @param <V> The type of value, usually a string.
+ * @return UI data model container that may update the persistent state.
+ */
+ private <K, V> TableField<Entry<K, V>> createTableField(
+ final ListProperty<Entry<K, V>> persist ) {
+ return ofListType( persist ).render( new SimpleTableControl<>() );
+ }
+
+ /**
+ * Creates the preferences dialog based using
+ * {@link SkeletonStorageHandler} and
+ * numerous {@link Category} objects.
+ *
+ * @return A component for editing preferences.
+ * @throws RuntimeException Could not construct the {@link PreferencesFx}
+ * object (e.g., illegal access permissions,
+ * unmapped XML resource).
+ */
+ private PreferencesFx createPreferencesFx() {
+ return PreferencesFx.of( createStorageHandler(), createCategories() )
+ .instantPersistent( false )
+ .dialogIcon( ICON_DIALOG );
+ }
+
+ /**
+ * Override the {@link PreferencesFx} storage handler to perform no actions.
+ * Persistence is accomplished using the {@link XmlStore}.
+ *
+ * @return A no-op {@link StorageHandler} implementation.
+ */
+ private StorageHandler createStorageHandler() {
+ return new SkeletonStorageHandler();
+ }
+
+ private Category[] createCategories() {
+ return new Category[]{
+ Category.of(
+ get( KEY_DOC ),
+ Group.of(
+ get( KEY_DOC_META ),
+ Setting.of( label( KEY_DOC_META ) ),
+ Setting.of( title( KEY_DOC_META ),
+ createTableField( listEntryProperty( KEY_DOC_META ) ),
+ listEntryProperty( KEY_DOC_META ) )
+ )
+ ),
+ Category.of(
+ get( KEY_TYPESET ),
+ Group.of(
+ get( KEY_TYPESET_CONTEXT ),
+ Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
+ Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
+ fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
+ Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
+ Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
+ booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
+ ),
+ Group.of(
+ get( KEY_TYPESET_TYPOGRAPHY ),
+ Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
+ Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
+ booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
+ )
+ ),
+ Category.of(
+ get( KEY_EDITOR ),
+ Group.of(
+ get( KEY_EDITOR_AUTOSAVE ),
+ Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
+ Setting.of( title( KEY_EDITOR_AUTOSAVE ),
+ integerProperty( KEY_EDITOR_AUTOSAVE ) )
+ )
+ ),
+ Category.of(
+ get( KEY_R ),
+ Group.of(
+ get( KEY_R_DIR ),
+ Setting.of( label( KEY_R_DIR,
+ stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
+ stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
+ Setting.of( title( KEY_R_DIR ),
+ fileProperty( KEY_R_DIR ), true )
+ ),
+ Group.of(
+ get( KEY_R_SCRIPT ),
+ Setting.of( label( KEY_R_SCRIPT ) ),
+ createMultilineSetting( "Script", KEY_R_SCRIPT )
+ ),
+ Group.of(
+ get( KEY_R_DELIM_BEGAN ),
+ Setting.of( label( KEY_R_DELIM_BEGAN ) ),
+ Setting.of( title( KEY_R_DELIM_BEGAN ),
+ stringProperty( KEY_R_DELIM_BEGAN ) )
+ ),
+ Group.of(
+ get( KEY_R_DELIM_ENDED ),
+ Setting.of( label( KEY_R_DELIM_ENDED ) ),
+ Setting.of( title( KEY_R_DELIM_ENDED ),
+ stringProperty( KEY_R_DELIM_ENDED ) )
+ )
+ ),
+ Category.of(
+ get( KEY_IMAGES ),
+ Group.of(
+ get( KEY_IMAGES_DIR ),
+ Setting.of( label( KEY_IMAGES_DIR ) ),
+ Setting.of( title( KEY_IMAGES_DIR ),
+ fileProperty( KEY_IMAGES_DIR ), true )
+ ),
+ Group.of(
+ get( KEY_IMAGES_ORDER ),
+ Setting.of( label( KEY_IMAGES_ORDER ) ),
+ Setting.of( title( KEY_IMAGES_ORDER ),
+ stringProperty( KEY_IMAGES_ORDER ) )
+ ),
+ Group.of(
+ get( KEY_IMAGES_RESIZE ),
+ Setting.of( label( KEY_IMAGES_RESIZE ) ),
+ Setting.of( title( KEY_IMAGES_RESIZE ),
+ booleanProperty( KEY_IMAGES_RESIZE ) )
+ ),
+ Group.of(
+ get( KEY_IMAGES_SERVER ),
+ Setting.of( label( KEY_IMAGES_SERVER ) ),
+ Setting.of( title( KEY_IMAGES_SERVER ),
+ stringProperty( KEY_IMAGES_SERVER ) )
+ )
+ ),
+ Category.of(
+ get( KEY_DEF ),
+ Group.of(
+ get( KEY_DEF_PATH ),
+ Setting.of( label( KEY_DEF_PATH ) ),
+ Setting.of( title( KEY_DEF_PATH ),
+ fileProperty( KEY_DEF_PATH ), false )
+ ),
+ Group.of(
+ get( KEY_DEF_DELIM_BEGAN ),
+ Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
+ Setting.of( title( KEY_DEF_DELIM_BEGAN ),
+ stringProperty( KEY_DEF_DELIM_BEGAN ) )
+ ),
+ Group.of(
+ get( KEY_DEF_DELIM_ENDED ),
+ Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
+ Setting.of( title( KEY_DEF_DELIM_ENDED ),
+ stringProperty( KEY_DEF_DELIM_ENDED ) )
+ )
+ ),
+ Category.of(
+ get( KEY_UI_FONT ),
+ Group.of(
+ get( KEY_UI_FONT_EDITOR ),
+ Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
+ Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
+ createFontNameField(
+ stringProperty( KEY_UI_FONT_EDITOR_NAME ),
+ doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
+ stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
+ Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
+ Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
+ doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
+ ),
+ Group.of(
+ get( KEY_UI_FONT_PREVIEW ),
+ Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
+ Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
+ createFontNameField(
+ stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
+ doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
+ stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
+ Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
+ Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
+ doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
+ Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
+ Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
+ createFontNameField(
+ stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
+ doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
+ stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
+ Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
+ Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
+ doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
+ )
+ ),
+ Category.of(
+ get( KEY_UI_SKIN ),
+ Group.of(
+ get( KEY_UI_SKIN_SELECTION ),
+ Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
+ Setting.of( title( KEY_UI_SKIN_SELECTION ),
+ skinListProperty(),
+ skinProperty( KEY_UI_SKIN_SELECTION ) )
+ ),
+ Group.of(
+ get( KEY_UI_SKIN_CUSTOM ),
+ Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
+ Setting.of( title( KEY_UI_SKIN_CUSTOM ),
+ fileProperty( KEY_UI_SKIN_CUSTOM ), false )
+ )
+ ),
+ Category.of(
+ get( KEY_UI_PREVIEW ),
+ Group.of(
+ get( KEY_UI_PREVIEW_STYLESHEET ),
+ Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
+ Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
+ fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
+ )
+ ),
+ Category.of(
+ get( KEY_LANGUAGE ),
+ Group.of(
+ get( KEY_LANGUAGE_LOCALE ),
+ Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
+ Setting.of( title( KEY_LANGUAGE_LOCALE ),
+ localeListProperty(),
+ localeProperty( KEY_LANGUAGE_LOCALE ) )
+ )
+ )};
+ }
+
+ @SuppressWarnings( "unchecked" )
+ private Setting<StringField, StringProperty> createMultilineSetting(
+ final String description, final Key property ) {
+ final Setting<StringField, StringProperty> setting =
+ Setting.of( description, stringProperty( property ) );
+ final var field = setting.getElement();
+ field.multiline( true );
+
+ return setting;
+ }
+
+ /**
+ * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively.
+ */
+ private void initKeyEventHandler( final PreferencesFx preferences ) {
+ final var view = preferences.getView();
+ final var nodes = view.getChildrenUnmodifiable();
+ final var master = (MasterDetailPane) nodes.get( 0 );
+ final var detail = (NavigationView) master.getDetailNode();
+ final var pane = (DialogPane) view.getParent();
+
+ detail.setOnKeyReleased( key -> {
+ switch( key.getCode() ) {
+ case ENTER -> ((Button) pane.lookupButton( OK )).fire();
+ case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
+ }
+ } );
+ }
+
+ /**
+ * Called when the user clicks the APPLY or OK buttons in the dialog.
+ *
+ * @param preferences Preferences widget.
+ */
+ private void initSaveEventHandler( final PreferencesFx preferences ) {
+ preferences.addEventHandler(
+ EVENT_PREFERENCES_SAVED, event -> mWorkspace.save()
+ );
+ }
+
+ /**
+ * Creates a label for the given key after interpolating its value.
+ *
+ * @param key The key to find in the resource bundle.
+ * @return The value of the key as a label.
+ */
+ private Node label( final Key key ) {
+ return label( key, (String[]) null );
+ }
+
+ private Node label( final Key key, final String... values ) {
+ return new Label( get( key.toString() + ".desc", (Object[]) values ) );
+ }
+
+ private String title( final Key key ) {
+ return get( key.toString() + ".title" );
+ }
+
+ private ObjectProperty<File> fileProperty( final Key key ) {
+ return mWorkspace.fileProperty( key );
+ }
+
+ private StringProperty stringProperty( final Key key ) {
+ return mWorkspace.stringProperty( key );
+ }
+
+ private BooleanProperty booleanProperty( final Key key ) {
+ return mWorkspace.booleanProperty( key );
+ }
+
+ @SuppressWarnings( "SameParameterValue" )
+ private IntegerProperty integerProperty( final Key key ) {
+ return mWorkspace.integerProperty( key );
+ }
+
+ @SuppressWarnings( "SameParameterValue" )
+ private DoubleProperty doubleProperty( final Key key ) {
+ return mWorkspace.doubleProperty( key );
+ }
+
+ private ObjectProperty<String> skinProperty( final Key key ) {
+ return mWorkspace.skinProperty( key );
+ }
+
+ private ObjectProperty<String> localeProperty( final Key key ) {
+ return mWorkspace.localeProperty( key );
+ }
+
+ private <K, V> ListProperty<Entry<K, V>> listEntryProperty( final Key key ) {
+ return mWorkspace.listsProperty( key );
}
}
src/main/java/com/keenwrite/preferences/SimpleTableControl.java
import javafx.scene.control.ButtonBar;
import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableColumn.CellEditEvent;
import javafx.scene.control.TableView;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiFunction;
import java.util.function.Function;
private static long sCounter;
- public SimpleTableControl() {
- }
+ public SimpleTableControl() {}
- /**
- * {@inheritDoc}
- */
@Override
public void initializeParts() {
button.setOnAction( handler );
return button;
+ }
+
+ private TableColumn<Entry<K, V>, K> createEditableColumnKey(
+ final TableView<Entry<K, V>> table ) {
+ return createColumn(
+ table,
+ Entry::getKey,
+ ( e, o ) -> new SimpleEntry<>( e.getNewValue(), o.getValue() ),
+ "Key",
+ .2
+ );
+ }
+
+ private TableColumn<Entry<K, V>, V> createEditableColumnValue(
+ final TableView<Entry<K, V>> table ) {
+ return createColumn(
+ table,
+ Entry::getValue,
+ ( e, o ) -> new SimpleEntry<>( o.getKey(), e.getNewValue() ),
+ "Value",
+ .8
+ );
}
+ /**
+ * Creates a table column having cells that be edited.
+ *
+ * @param table The table to which the column belongs.
+ * @param mapEntry Data model backing the edited text.
+ * @param label Column name.
+ * @param width Fraction of table width (1 = 100%).
+ * @param <T> The return type for the column (i.e., key or value).
+ * @return The newly configured column.
+ */
private <T> TableColumn<Entry<K, V>, T> createColumn(
final TableView<Entry<K, V>> table,
final Function<Entry<K, V>, T> mapEntry,
+ final BiFunction<CellEditEvent<Entry<K, V>, T>, Entry<K, V>, Entry<K, V>> creator,
final String label,
final double width
) {
final var column = new TableColumn<Entry<K, V>, T>( label );
column.setEditable( true );
column.setResizable( true );
column.prefWidthProperty().bind( table.widthProperty().multiply( width ) );
+
+ column.setOnEditCommit( event -> {
+ final var index = event.getTablePosition().getRow();
+ final var view = event.getTableView();
+ final var old = view.getItems().get( index );
+
+ // Update the data model with the new column value.
+ view.getItems().set( index, creator.apply( event, old ) );
+ } );
+
column.setCellValueFactory(
- cellData -> new SimpleObjectProperty<>(
- mapEntry.apply( cellData.getValue() )
- )
+ cellData ->
+ new SimpleObjectProperty<>( mapEntry.apply( cellData.getValue() ) )
);
+
column.setCellFactory(
tableColumn -> new AltTableCell<>(
return column;
- }
-
- private TableColumn<Entry<K, V>, K> createEditableColumnKey(
- final TableView<Entry<K, V>> table ) {
- return createColumn( table, Entry::getKey, "Key", .3 );
- }
-
- private TableColumn<Entry<K, V>, V> createEditableColumnValue(
- final TableView<Entry<K, V>> table ) {
- return createColumn( table, Entry::getValue, "Value", .7 );
}
+ /**
+ * Calling {@link #initializeParts()} also performs layout because no handles
+ * are kept to the widgets after initialization.
+ */
@Override
- public void layoutParts() {
- }
+ public void layoutParts() {}
}
src/main/java/com/keenwrite/preferences/SkeletonStorageHandler.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.preferences;
+
+import com.dlsc.preferencesfx.PreferencesFx;
+import com.dlsc.preferencesfx.util.StorageHandler;
+import javafx.collections.ObservableList;
+
+import java.util.prefs.Preferences;
+
+/**
+ * Prevents {@link PreferencesFx} from saving. Saving and loading preferences
+ * and application window state is accomplished by the {@link Workspace}. This
+ * is required to change the user preferences file location and data format.
+ *
+ * @see XmlStore
+ * @see Workspace
+ */
+public final class SkeletonStorageHandler implements StorageHandler {
+ @Override
+ public void saveSelectedCategory( final String breadcrumb ) {}
+
+ @Override
+ public String loadSelectedCategory() {
+ return "";
+ }
+
+ @Override
+ public void saveDividerPosition( final double dividerPosition ) {}
+
+ @Override
+ public double loadDividerPosition() {
+ return 0;
+ }
+
+ @Override
+ public void saveWindowWidth( final double windowWidth ) {}
+
+ @Override
+ public double loadWindowWidth() {
+ return 0;
+ }
+
+ @Override
+ public void saveWindowHeight( final double windowHeight ) {}
+
+ @Override
+ public double loadWindowHeight() {
+ return 0;
+ }
+
+ @Override
+ public void saveWindowPosX( final double windowPosX ) {}
+
+ @Override
+ public double loadWindowPosX() {
+ return 0;
+ }
+
+ @Override
+ public void saveWindowPosY( final double windowPosY ) {}
+
+ @Override
+ public double loadWindowPosY() {
+ return 0;
+ }
+
+ @Override
+ public void saveObject( final String breadcrumb, final Object object ) {}
+
+ @Override
+ public Object loadObject(
+ final String breadcrumb, final Object defaultObject ) {
+ return defaultObject;
+ }
+
+ @Override
+ public <T> T loadObject(
+ final String breadcrumb, final Class<T> type, final T defaultObject ) {
+ return defaultObject;
+ }
+
+ @Override
+ @SuppressWarnings( "rawtypes" )
+ public ObservableList loadObservableList(
+ final String breadcrumb, final ObservableList defaultObservableList ) {
+ return defaultObservableList;
+ }
+
+ @Override
+ public <T> ObservableList<T> loadObservableList(
+ final String breadcrumb,
+ final Class<T> type,
+ final ObservableList<T> defaultObservableList ) {
+ return defaultObservableList;
+ }
+
+ @Override
+ public boolean clearPreferences() {
+ return false;
+ }
+
+ @Override
+ public Preferences getPreferences() {
+ return null;
+ }
+}
src/main/java/com/keenwrite/preferences/TableField.java
public void setBindingMode( final BindingMode bindingMode ) {
if( CONTINUOUS.equals( bindingMode ) ) {
- mViewProperty.get().addAll( mSaveProperty.get() );
+ mViewProperty.addAll( mSaveProperty );
}
}
/**
* Answers whether the user input is valid.
*
- * @return {@code true} Users may provide any strings.
+ * @return {@code true} Users may provide any key or value strings.
*/
@Override
protected boolean validate() {
return true;
}
/**
* Update the properties to save by copying the properties updated in the
- * user interface (i.e., the view).
+ * user interface (i.e., the view). To be clear, the properties are not
+ * persisted after calling this method, merely moved out of the UI data
+ * model and into the to-be-saved data model.
*/
@Override
public void persist() {
- mSaveProperty.get().addAll( mViewProperty.get() );
+ mSaveProperty.clear();
+ mSaveProperty.addAll( mViewProperty );
}
src/main/java/com/keenwrite/preferences/Workspace.java
import javafx.beans.property.*;
import javafx.collections.ObservableList;
-import org.apache.commons.configuration2.XMLConfiguration;
-import org.apache.commons.configuration2.builder.fluent.Configurations;
-import org.apache.commons.configuration2.io.FileHandler;
-
-import java.io.File;
-import java.time.Year;
-import java.time.ZonedDateTime;
-import java.util.*;
-import java.util.Map.Entry;
-import java.util.function.BiConsumer;
-import java.util.function.BooleanSupplier;
-import java.util.function.Consumer;
-import java.util.function.Function;
-
-import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
-import static com.keenwrite.Launcher.getVersion;
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.AppKeys.*;
-import static java.lang.String.valueOf;
-import static java.lang.System.getProperty;
-import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
-import static java.util.Map.entry;
-import static javafx.application.Platform.runLater;
-import static javafx.collections.FXCollections.observableArrayList;
-import static javafx.collections.FXCollections.observableSet;
-
-/**
- * Responsible for defining behaviours for separate projects. A workspace has
- * the ability to save and restore a session, including the window dimensions,
- * tab setup, files, and user preferences.
- * <p>
- * The configuration must support hierarchical (nested) configuration nodes
- * to persist the user interface state. Although possible with a flat
- * configuration file, it's not nearly as simple or elegant.
- * </p>
- * <p>
- * Neither JSON nor HOCON support schema validation and versioning, which makes
- * XML the more suitable configuration file format. Schema validation and
- * versioning provide future-proofing and ease of reading and upgrading previous
- * versions of the configuration file.
- * </p>
- * <p>
- * Persistent preferences may be set directly by the user or indirectly by
- * the act of using the application.
- * </p>
- * <p>
- * Note the following definitions:
- * </p>
- * <dl>
- * <dt>File</dt>
- * <dd>References a file name (no path), path, or directory.</dd>
- * <dt>Path</dt>
- * <dd>Fully qualified file name, which includes all parent directories.</dd>
- * <dt>Dir</dt>
- * <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
- * </dl>
- */
-public final class Workspace implements KeyConfiguration {
- private final Map<Key, Property<?>> VALUES = Map.ofEntries(
- entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
- entry( KEY_META_NAME, asStringProperty( "default" ) ),
-
- entry( KEY_DOC_TITLE, asStringProperty( "title" ) ),
- entry( KEY_DOC_AUTHOR, asStringProperty( getProperty( "user.name" ) ) ),
- entry( KEY_DOC_BYLINE, asStringProperty( getProperty( "user.name" ) ) ),
- entry( KEY_DOC_ADDRESS, asStringProperty( "" ) ),
- entry( KEY_DOC_PHONE, asStringProperty( "" ) ),
- entry( KEY_DOC_EMAIL, asStringProperty( "" ) ),
- entry( KEY_DOC_KEYWORDS, asStringProperty( "science, nature" ) ),
- entry( KEY_DOC_COPYRIGHT, asStringProperty( getYear() ) ),
- entry( KEY_DOC_DATE, asStringProperty( getDate() ) ),
-
- entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
-
- entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
- entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
- entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
- entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
-
- entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
- entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
- entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
- entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
-
- entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
- entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
- entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
-
- entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
- entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
- entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
- entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
-
- //@formatter:off
- entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
- entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
- entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
- entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
- entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
- entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
-
- entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
- entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
- entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
- entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
- entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
- entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
-
- entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
- entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
-
- entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
-
- entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
-
- entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
- entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
- entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
- entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
- //@formatter:on
- );
-
- /**
- * Helps instantiate {@link Property} instances for XML configuration items.
- */
- private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
- Map.of(
- LocaleProperty.class, LocaleProperty::parseLocale,
- SimpleBooleanProperty.class, Boolean::parseBoolean,
- SimpleIntegerProperty.class, Integer::parseInt,
- SimpleDoubleProperty.class, Double::parseDouble,
- SimpleFloatProperty.class, Float::parseFloat,
- FileProperty.class, File::new
- );
-
- private static final Map<Class<?>, Function<String, Object>> MARSHALL =
- Map.of(
- LocaleProperty.class, LocaleProperty::toLanguageTag
- );
-
- private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
- entry(
- KEY_UI_FILES_PATH,
- createSetProperty( new HashSet<String>() )
- )
- );
-
- private final Map<Key, ListProperty<?>> LISTS = Map.ofEntries(
- entry(
- KEY_DOC_META,
- createListProperty( new LinkedList<Entry<String, String>>() )
- )
- );
-
- /**
- * Creates a new {@link Workspace} that will attempt to load a configuration
- * file. If the configuration file cannot be loaded, the workspace settings
- * will return default values. This allows unit tests to provide an instance
- * of {@link Workspace} when necessary without encountering failures.
- */
- public Workspace() {
- load( FILE_PREFERENCES );
- }
-
- /**
- * Creates a new {@link Workspace} that will attempt to load the given
- * configuration file.
- *
- * @param filename The file to load.
- */
- public Workspace( final String filename ) {
- load( filename );
- }
-
- /**
- * Returns a value that represents a setting in the application that the user
- * may configure, either directly or indirectly.
- *
- * @param key The reference to the users' preference stored in deference
- * of app reëntrance.
- * @return An observable property to be persisted.
- */
- @SuppressWarnings( "unchecked" )
- public <T, U extends Property<T>> U valuesProperty( final Key key ) {
- assert key != null;
- // The type that goes into the map must come out.
- return (U) VALUES.get( key );
- }
-
- /**
- * Returns a set of values that represent a setting in the application that
- * the user may configure, either directly or indirectly. The property
- * returned is backed by a {@link Set}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return An observable property to be persisted.
- */
- @SuppressWarnings( "unchecked" )
- public <T> SetProperty<T> setsProperty( final Key key ) {
- assert key != null;
- // The type that goes into the map must come out.
- return (SetProperty<T>) SETS.get( key );
- }
-
- /**
- * Returns a list of values that represent a setting in the application that
- * the user may configure, either directly or indirectly. The property
- * returned is backed by a mutable {@link List}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return An observable property to be persisted.
- */
- @SuppressWarnings( "unchecked" )
- public <T> ListProperty<T> listsProperty( final Key key ) {
- assert key != null;
- return (ListProperty<T>) LISTS.get( key );
- }
-
- /**
- * Creates an instance of {@link ObservableList} that is based on a
- * modifiable observable array list for the given items.
- *
- * @param items The items to wrap in an observable list.
- * @param <E> The type of items to add to the list.
- * @return An observable property that can have its contents modified.
- */
- public static <E> ObservableList<E> listProperty( final Set<E> items ) {
- return new SimpleListProperty<>( observableArrayList( items ) );
- }
-
- private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
- return new SimpleSetProperty<>( observableSet( set ) );
- }
-
- private static <E> ListProperty<E> createListProperty( final List<E> list ) {
- return new SimpleListProperty<>( observableArrayList( list ) );
-
- }
-
- /**
- * @param value Default value.
- */
- private static StringProperty asStringProperty( final String value ) {
- return new SimpleStringProperty( value );
- }
-
- private static BooleanProperty asBooleanProperty() {
- return new SimpleBooleanProperty();
- }
-
- /**
- * @param value Default value.
- */
- @SuppressWarnings( "SameParameterValue" )
- private static BooleanProperty asBooleanProperty( final boolean value ) {
- return new SimpleBooleanProperty( value );
- }
-
- /**
- * @param value Default value.
- */
- @SuppressWarnings( "SameParameterValue" )
- private static IntegerProperty asIntegerProperty( final int value ) {
- return new SimpleIntegerProperty( value );
- }
-
- /**
- * @param value Default value.
- */
- private static DoubleProperty asDoubleProperty( final double value ) {
- return new SimpleDoubleProperty( value );
- }
-
- /**
- * @param value Default value.
- */
- private static FileProperty asFileProperty( final File value ) {
- return new FileProperty( value );
- }
-
- /**
- * @param value Default value.
- */
- @SuppressWarnings( "SameParameterValue" )
- private static LocaleProperty asLocaleProperty( final Locale value ) {
- return new LocaleProperty( value );
- }
-
- /**
- * @param value Default value.
- */
- @SuppressWarnings( "SameParameterValue" )
- private static SkinProperty asSkinProperty( final String value ) {
- return new SkinProperty( value );
- }
-
- /**
- * Returns the {@link String} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public StringProperty stringProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- /**
- * Returns the {@link Boolean} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public BooleanProperty booleanProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- /**
- * Returns the {@link Integer} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public IntegerProperty integerProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- /**
- * Returns the {@link Double} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public DoubleProperty doubleProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- /**
- * Returns the {@link File} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public ObjectProperty<File> fileProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- /**
- * Returns the {@link Locale} {@link Property} associated with the given
- * {@link Key} from the internal list of preference values. The caller
- * must be sure that the given {@link Key} is associated with a {@link File}
- * {@link Property}.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- public LocaleProperty localeProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- public ObjectProperty<String> skinProperty( final Key key ) {
- assert key != null;
- return valuesProperty( key );
- }
-
- @Override
- public String getString( final Key key ) {
- assert key != null;
- return stringProperty( key ).get();
- }
-
- /**
- * Returns the {@link Boolean} preference value associated with the given
- * {@link Key}. The caller must be sure that the given {@link Key} is
- * associated with a value that matches the return type.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- @Override
- public boolean getBoolean( final Key key ) {
- assert key != null;
- return booleanProperty( key ).get();
- }
-
- /**
- * Returns the {@link Integer} preference value associated with the given
- * {@link Key}. The caller must be sure that the given {@link Key} is
- * associated with a value that matches the return type.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- @Override
- public int getInteger( final Key key ) {
- assert key != null;
- return integerProperty( key ).get();
- }
-
- /**
- * Returns the {@link Double} preference value associated with the given
- * {@link Key}. The caller must be sure that the given {@link Key} is
- * associated with a value that matches the return type.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- @Override
- public double getDouble( final Key key ) {
- assert key != null;
- return doubleProperty( key ).get();
- }
-
- /**
- * Returns the {@link File} preference value associated with the given
- * {@link Key}. The caller must be sure that the given {@link Key} is
- * associated with a value that matches the return type.
- *
- * @param key The {@link Key} associated with a preference value.
- * @return The value associated with the given {@link Key}.
- */
- @Override
- public File getFile( final Key key ) {
- assert key != null;
- return fileProperty( key ).get();
- }
-
- /**
- * Returns the language locale setting for the
- * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
- *
- * @return The user's current locale setting.
- */
- public Locale getLocale() {
- return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
- }
-
- private Sigils createSigils( final Key keyBegan, final Key keyEnded ) {
- assert keyBegan != null;
- assert keyEnded != null;
-
- return new Sigils( getString( keyBegan ), getString( keyEnded ) );
- }
-
- public SigilOperator createYamlSigilOperator() {
- return new YamlSigilOperator(
- createSigils( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED )
- );
- }
-
- public SigilOperator createRSigilOperator() {
- return new RSigilOperator(
- createSigils( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ),
- createYamlSigilOperator()
- );
- }
-
- /**
- * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
- * providing a value of {@code true} for the {@link BooleanSupplier} to
- * indicate the property changes always take effect.
- *
- * @param key The value to bind to the internal key property.
- * @param property The external property value that sets the internal value.
- */
- public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
- listen( key, property, () -> true );
- }
-
- /**
- * Binds a read-only property to a value in the preferences. This allows
- * user interface properties to change and the preferences will be
- * synchronized automatically.
- * <p>
- * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
- * application window states are finished before assessing whether property
- * changes should be applied. Without this, exiting the application while the
- * window is maximized would persist the window's maximum dimensions,
- * preventing restoration to its prior, non-maximum size.
- * </p>
- *
- * @param key The value to bind to the internal key property.
- * @param property The external property value that sets the internal value.
- * @param enabled Indicates whether property changes should be applied.
- */
- public <T> void listen(
- final Key key,
- final ReadOnlyProperty<T> property,
- final BooleanSupplier enabled ) {
- property.addListener(
- ( c, o, n ) -> runLater( () -> {
- if( enabled.getAsBoolean() ) {
- valuesProperty( key ).setValue( n );
- }
- } )
- );
- }
-
- public void loadValueKeys( final Consumer<Key> consumer ) {
- VALUES.keySet().forEach( consumer );
- }
-
- public void loadSetKeys( final Consumer<Key> consumer ) {
- SETS.keySet().forEach( consumer );
- }
-
- public void loadListKeys( final Consumer<Key> consumer ) {
- LISTS.keySet().forEach( consumer );
- }
-
- /**
- * Calls the given consumer for all single-value keys. Sets use
- * {@link #saveSets(BiConsumer)} and lists use {@link #saveLists(BiConsumer)}.
- *
- * @param consumer Called to accept each preference key value.
- */
- public void saveValues( final BiConsumer<Key, Property<?>> consumer ) {
- VALUES.forEach( consumer );
- }
-
- /**
- * Calls the given consumer for all multi-value keys. For single items, see
- * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
- * over the list of items retrieved through this method.
- *
- * @param consumer Called to accept each preference key in the set.
- */
- public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
- SETS.forEach( consumer );
- }
-
- /**
- * Calls the given consumer for all multi-value keys. For single items, see
- * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating
- * over the list of items retrieved through this method.
- *
- * @param consumer Called to accept each preference key in the list.
- */
- public void saveLists( final BiConsumer<Key, ListProperty<?>> consumer ) {
- LISTS.forEach( consumer );
- }
-
- /**
- * Saves the current workspace.
- */
- public void save() {
- try {
- final var config = new XMLConfiguration();
-
- // The root config key can only be set for an empty configuration file.
- config.setRootElementName( APP_TITLE_LOWERCASE );
- valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
-
- saveValues(
- ( key, property ) ->
- config.setProperty( key.toString(), marshall( property ) )
- );
-
- saveSets(
- ( key, set ) -> {
- final var keyName = key.toString();
- set.forEach( value -> config.addProperty( keyName, value ) );
- }
- );
-
- saveLists(
- ( key, list ) -> {
- final var keyName = key.toString();
- list.forEach(
- value -> System.out.printf( "SAVE K/V: %s = %s%n", keyName, value )
- );
- }
- );
-
- new FileHandler( config ).save( FILE_PREFERENCES );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- /**
- * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
- * If not found, this will fall back to an empty configuration file, leaving
- * the application to fill in default values.
- *
- * @param filename The file containing user preferences to load.
- */
- private void load( final String filename ) {
- try {
- final var config = new Configurations().xml( filename );
-
- loadValueKeys( key -> {
- final var configValue = config.getProperty( key.toString() );
-
- // Allow other properties to load, even if any are missing.
- if( configValue != null ) {
- final var property = valuesProperty( key );
- property.setValue( unmarshall( property, configValue ) );
- }
- } );
-
- loadSetKeys( key -> {
- final var configSet =
- new LinkedHashSet<>( config.getList( key.toString() ) );
- final var property = setsProperty( key );
- property.setValue( observableSet( configSet ) );
- } );
-
- loadListKeys( key -> {
- System.out.println( "LOAD LIST KEY: " + key );
- final var configList =
- new LinkedList<>( config.getList( key.toString() ) );
- final var property = listsProperty( key );
- property.setValue( observableArrayList( configList ) );
- } );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private Object unmarshall(
- final Property<?> property, final Object configValue ) {
- final var setting = configValue.toString();
-
- return UNMARSHALL
- .getOrDefault( property.getClass(), ( value ) -> value )
- .apply( setting );
- }
-
- private Object marshall( final Property<?> property ) {
- return property.getValue() == null
- ? null
- : MARSHALL
- .getOrDefault( property.getClass(), ( __ ) -> property.getValue() )
- .apply( property.getValue().toString() );
- }
-
- /**
- * Returns the current year. This is used to populate the default year
- * (e.g., for copyright notices).
- *
- * @return The current year.
- */
- private String getYear() {
- return valueOf( Year.now().getValue() );
- }
-
- /**
- * Returns the current date. This is used to populate the default date
- * (e.g., for document publication date).
- *
- * @return The current year.
- */
- private String getDate() {
- return ZonedDateTime.now().format( RFC_1123_DATE_TIME );
+
+import java.io.File;
+import java.util.*;
+import java.util.Map.Entry;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+
+import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
+import static com.keenwrite.Launcher.getVersion;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.preferences.AppKeys.*;
+import static java.util.Map.entry;
+import static javafx.application.Platform.runLater;
+import static javafx.collections.FXCollections.observableArrayList;
+import static javafx.collections.FXCollections.observableSet;
+
+/**
+ * Responsible for defining behaviours for separate projects. A workspace has
+ * the ability to save and restore a session, including the window dimensions,
+ * tab setup, files, and user preferences.
+ * <p>
+ * The configuration must support hierarchical (nested) configuration nodes
+ * to persist the user interface state. Although possible with a flat
+ * configuration file, it's not nearly as simple or elegant.
+ * </p>
+ * <p>
+ * Neither JSON nor HOCON support schema validation and versioning, which makes
+ * XML the more suitable configuration file format. Schema validation and
+ * versioning provide future-proofing and ease of reading and upgrading previous
+ * versions of the configuration file.
+ * </p>
+ * <p>
+ * Persistent preferences may be set directly by the user or indirectly by
+ * the act of using the application.
+ * </p>
+ * <p>
+ * Note the following definitions:
+ * </p>
+ * <dl>
+ * <dt>File</dt>
+ * <dd>References a file name (no path), path, or directory.</dd>
+ * <dt>Path</dt>
+ * <dd>Fully qualified file name, which includes all parent directories.</dd>
+ * <dt>Dir</dt>
+ * <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
+ * </dl>
+ */
+public final class Workspace implements KeyConfiguration {
+ private final Map<Key, Property<?>> VALUES = Map.ofEntries(
+ entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
+ entry( KEY_META_NAME, asStringProperty( "default" ) ),
+
+ entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
+
+ entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
+ entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
+ entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
+
+ entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
+ entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
+ entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
+
+ entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
+ entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
+ entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
+
+ entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
+ entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
+ entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
+
+ //@formatter:off
+ entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
+ entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
+
+ entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
+ entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
+
+ entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
+ entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
+
+ entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
+
+ entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
+
+ entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
+ entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
+ entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
+ //@formatter:on
+ );
+
+ /**
+ * Helps instantiate {@link Property} instances for XML configuration items.
+ */
+ private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
+ Map.of(
+ LocaleProperty.class, LocaleProperty::parseLocale,
+ SimpleBooleanProperty.class, Boolean::parseBoolean,
+ SimpleIntegerProperty.class, Integer::parseInt,
+ SimpleDoubleProperty.class, Double::parseDouble,
+ SimpleFloatProperty.class, Float::parseFloat,
+ FileProperty.class, File::new
+ );
+
+ private static final Map<Class<?>, Function<String, Object>> MARSHALL =
+ Map.of(
+ LocaleProperty.class, LocaleProperty::toLanguageTag
+ );
+
+ private final Map<Key, SetProperty<?>> SETS = Map.ofEntries(
+ entry(
+ KEY_UI_FILES_PATH,
+ createSetProperty( new HashSet<String>() )
+ )
+ );
+
+ private final Map<Key, ListProperty<?>> LISTS = Map.ofEntries(
+ entry(
+ KEY_DOC_META,
+ createListProperty( new LinkedList<Entry<String, String>>() )
+ )
+ );
+
+ private final XmlStore mStore;
+
+ /**
+ * Creates a new {@link Workspace} using values found in the given
+ * {@link XmlStore}.
+ *
+ * @param store Contains user preferences, usually persisted.
+ */
+ public Workspace( final XmlStore store ) {
+ mStore = store;
+ }
+
+ /**
+ * Creates a new {@link Workspace} that will attempt to load the given
+ * configuration file. If the configuration file cannot be loaded, the
+ * workspace settings will return default values. This creates an instance
+ * of {@link XmlStore} to load and parse the user preferences.
+ *
+ * @param file The file to load.
+ */
+ public Workspace( final File file ) {
+ // Root-level configuration item is the application name.
+ this( new XmlStore( file, APP_TITLE_LOWERCASE ) );
+ load( mStore );
+ }
+
+ /**
+ * Returns a value that represents a setting in the application that the user
+ * may configure, either directly or indirectly.
+ *
+ * @param key The reference to the users' preference stored in deference
+ * of app reëntrance.
+ * @return An observable property to be persisted.
+ */
+ @SuppressWarnings( "unchecked" )
+ public <T, U extends Property<T>> U valuesProperty( final Key key ) {
+ assert key != null;
+ // The type that goes into the map must come out.
+ return (U) VALUES.get( key );
+ }
+
+ /**
+ * Returns a set of values that represent a setting in the application that
+ * the user may configure, either directly or indirectly. The property
+ * returned is backed by a {@link Set}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return An observable property to be persisted.
+ */
+ @SuppressWarnings( "unchecked" )
+ public <T> SetProperty<T> setsProperty( final Key key ) {
+ assert key != null;
+ // The type that goes into the map must come out.
+ return (SetProperty<T>) SETS.get( key );
+ }
+
+ /**
+ * Returns a list of values that represent a setting in the application that
+ * the user may configure, either directly or indirectly. The property
+ * returned is backed by a mutable {@link List}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return An observable property to be persisted.
+ */
+ @SuppressWarnings( "unchecked" )
+ public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
+ assert key != null;
+ return (ListProperty<Entry<K, V>>) LISTS.get( key );
+ }
+
+ /**
+ * Creates an instance of {@link ObservableList} that is based on a
+ * modifiable observable array list for the given items.
+ *
+ * @param items The items to wrap in an observable list.
+ * @param <E> The type of items to add to the list.
+ * @return An observable property that can have its contents modified.
+ */
+ public static <E> ObservableList<E> listProperty( final Set<E> items ) {
+ return new SimpleListProperty<>( observableArrayList( items ) );
+ }
+
+ private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
+ return new SimpleSetProperty<>( observableSet( set ) );
+ }
+
+ private static <E> ListProperty<E> createListProperty( final List<E> list ) {
+ return new SimpleListProperty<>( observableArrayList( list ) );
+ }
+
+ /**
+ * @param value Default value.
+ */
+ private static StringProperty asStringProperty( final String value ) {
+ return new SimpleStringProperty( value );
+ }
+
+ private static BooleanProperty asBooleanProperty() {
+ return new SimpleBooleanProperty();
+ }
+
+ /**
+ * @param value Default value.
+ */
+ @SuppressWarnings( "SameParameterValue" )
+ private static BooleanProperty asBooleanProperty( final boolean value ) {
+ return new SimpleBooleanProperty( value );
+ }
+
+ /**
+ * @param value Default value.
+ */
+ @SuppressWarnings( "SameParameterValue" )
+ private static IntegerProperty asIntegerProperty( final int value ) {
+ return new SimpleIntegerProperty( value );
+ }
+
+ /**
+ * @param value Default value.
+ */
+ private static DoubleProperty asDoubleProperty( final double value ) {
+ return new SimpleDoubleProperty( value );
+ }
+
+ /**
+ * @param value Default value.
+ */
+ private static FileProperty asFileProperty( final File value ) {
+ return new FileProperty( value );
+ }
+
+ /**
+ * @param value Default value.
+ */
+ @SuppressWarnings( "SameParameterValue" )
+ private static LocaleProperty asLocaleProperty( final Locale value ) {
+ return new LocaleProperty( value );
+ }
+
+ /**
+ * @param value Default value.
+ */
+ @SuppressWarnings( "SameParameterValue" )
+ private static SkinProperty asSkinProperty( final String value ) {
+ return new SkinProperty( value );
+ }
+
+ /**
+ * Returns the {@link String} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public StringProperty stringProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the {@link Boolean} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public BooleanProperty booleanProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the {@link Integer} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public IntegerProperty integerProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the {@link Double} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public DoubleProperty doubleProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the {@link File} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public ObjectProperty<File> fileProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the {@link Locale} {@link Property} associated with the given
+ * {@link Key} from the internal list of preference values. The caller
+ * must be sure that the given {@link Key} is associated with a {@link File}
+ * {@link Property}.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ public LocaleProperty localeProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ public ObjectProperty<String> skinProperty( final Key key ) {
+ assert key != null;
+ return valuesProperty( key );
+ }
+
+ @Override
+ public String getString( final Key key ) {
+ assert key != null;
+ return stringProperty( key ).get();
+ }
+
+ /**
+ * Returns the {@link Boolean} preference value associated with the given
+ * {@link Key}. The caller must be sure that the given {@link Key} is
+ * associated with a value that matches the return type.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ @Override
+ public boolean getBoolean( final Key key ) {
+ assert key != null;
+ return booleanProperty( key ).get();
+ }
+
+ /**
+ * Returns the {@link Integer} preference value associated with the given
+ * {@link Key}. The caller must be sure that the given {@link Key} is
+ * associated with a value that matches the return type.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ @Override
+ public int getInteger( final Key key ) {
+ assert key != null;
+ return integerProperty( key ).get();
+ }
+
+ /**
+ * Returns the {@link Double} preference value associated with the given
+ * {@link Key}. The caller must be sure that the given {@link Key} is
+ * associated with a value that matches the return type.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ @Override
+ public double getDouble( final Key key ) {
+ assert key != null;
+ return doubleProperty( key ).get();
+ }
+
+ /**
+ * Returns the {@link File} preference value associated with the given
+ * {@link Key}. The caller must be sure that the given {@link Key} is
+ * associated with a value that matches the return type.
+ *
+ * @param key The {@link Key} associated with a preference value.
+ * @return The value associated with the given {@link Key}.
+ */
+ @Override
+ public File getFile( final Key key ) {
+ assert key != null;
+ return fileProperty( key ).get();
+ }
+
+ /**
+ * Returns the language locale setting for the
+ * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
+ *
+ * @return The user's current locale setting.
+ */
+ public Locale getLocale() {
+ return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
+ }
+
+ private Sigils createSigils( final Key keyBegan, final Key keyEnded ) {
+ assert keyBegan != null;
+ assert keyEnded != null;
+
+ return new Sigils( getString( keyBegan ), getString( keyEnded ) );
+ }
+
+ public SigilOperator createYamlSigilOperator() {
+ return new YamlSigilOperator(
+ createSigils( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED )
+ );
+ }
+
+ public SigilOperator createRSigilOperator() {
+ return new RSigilOperator(
+ createSigils( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ),
+ createYamlSigilOperator()
+ );
+ }
+
+ /**
+ * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
+ * providing a value of {@code true} for the {@link BooleanSupplier} to
+ * indicate the property changes always take effect.
+ *
+ * @param key The value to bind to the internal key property.
+ * @param property The external property value that sets the internal value.
+ */
+ public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
+ listen( key, property, () -> true );
+ }
+
+ /**
+ * Binds a read-only property to a value in the preferences. This allows
+ * user interface properties to change and the preferences will be
+ * synchronized automatically.
+ * <p>
+ * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
+ * application window states are finished before assessing whether property
+ * changes should be applied. Without this, exiting the application while the
+ * window is maximized would persist the window's maximum dimensions,
+ * preventing restoration to its prior, non-maximum size.
+ * </p>
+ *
+ * @param key The value to bind to the internal key property.
+ * @param property The external property value that sets the internal value.
+ * @param enabled Indicates whether property changes should be applied.
+ */
+ public <T> void listen(
+ final Key key,
+ final ReadOnlyProperty<T> property,
+ final BooleanSupplier enabled ) {
+ property.addListener(
+ ( c, o, n ) -> runLater( () -> {
+ if( enabled.getAsBoolean() ) {
+ valuesProperty( key ).setValue( n );
+ }
+ } )
+ );
+ }
+
+ /**
+ * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file.
+ * If not found, this will fall back to an empty configuration file, leaving
+ * the application to fill in default values.
+ *
+ * @param store Container of user preferences to load.
+ */
+ public void load( final XmlStore store ) {
+ VALUES.keySet().forEach( key -> {
+ final var value = store.getValue( key );
+ final var property = valuesProperty( key );
+
+ property.setValue( unmarshall( property, value ) );
+ } );
+
+ SETS.keySet().forEach( key -> {
+ final var set = store.getSet( key );
+ final SetProperty<String> property = setsProperty( key );
+
+ property.setValue( observableSet( set ) );
+ } );
+
+ LISTS.keySet().forEach( key -> {
+ final var map = store.getMap( key );
+ final ListProperty<Entry<String, String>> property = listsProperty( key );
+ final var list = map
+ .entrySet()
+ .stream()
+ .toList();
+
+ property.setValue( observableArrayList( list ) );
+ } );
+ }
+
+ /**
+ * Saves the current workspace.
+ */
+ public void save() {
+ assert mStore != null;
+
+ final var store = mStore;
+
+ try {
+ // Update the string values to include the application version.
+ valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
+
+ VALUES.forEach( ( key, val ) -> store.setValue( key, marshall( val ) ) );
+ SETS.forEach( store::setSet );
+ LISTS.forEach( store::setMap );
+
+ store.save( FILE_PREFERENCES );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ /**
+ * Converts the given {@link Property} value to a string.
+ *
+ * @param property The {@link Property} to convert.
+ * @return A string representation of the given property, or the empty
+ * string if no conversion was possible.
+ */
+ private String marshall( final Property<?> property ) {
+ return property.getValue() == null
+ ? ""
+ : MARSHALL
+ .getOrDefault( property.getClass(), __ -> property.getValue() )
+ .apply( property.getValue().toString() )
+ .toString();
+ }
+
+ private Object unmarshall(
+ final Property<?> property, final Object configValue ) {
+ final var setting = configValue.toString();
+
+ return UNMARSHALL
+ .getOrDefault( property.getClass(), value -> value )
+ .apply( setting );
}
}
src/main/java/com/keenwrite/preferences/XmlStorageHandler.java
-/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
-package com.keenwrite.preferences;
-
-import com.dlsc.preferencesfx.PreferencesFx;
-import com.dlsc.preferencesfx.util.StorageHandler;
-import javafx.collections.ObservableList;
-
-import java.util.prefs.Preferences;
-
-/**
- * Prevents {@link PreferencesFx} from saving. Saving and loading preferences
- * and application window state is accomplished by the {@link Workspace}.
- * <p>
- * This implies that undo/redo functionality must be disabled because the
- * {@link Workspace} does not preserve previous states.
- * </p>
- */
-public final class XmlStorageHandler implements StorageHandler {
- @Override
- public void saveSelectedCategory( final String breadcrumb ) { }
-
- @Override
- public String loadSelectedCategory() {
- return "";
- }
-
- @Override
- public void saveDividerPosition( final double dividerPosition ) {
- }
-
- @Override
- public double loadDividerPosition() {
- return 0;
- }
-
- @Override
- public void saveWindowWidth( final double windowWidth ) { }
-
- @Override
- public double loadWindowWidth() {
- return 0;
- }
-
- @Override
- public void saveWindowHeight( final double windowHeight ) { }
-
- @Override
- public double loadWindowHeight() {
- return 0;
- }
-
- @Override
- public void saveWindowPosX( final double windowPosX ) { }
-
- @Override
- public double loadWindowPosX() {
- return 0;
- }
-
- @Override
- public void saveWindowPosY( final double windowPosY ) { }
-
- @Override
- public double loadWindowPosY() {
- return 0;
- }
-
- @Override
- public void saveObject( final String breadcrumb, final Object object ) { }
-
- @Override
- public Object loadObject(
- final String breadcrumb, final Object defaultObject ) {
- return defaultObject;
- }
-
- @Override
- public <T> T loadObject(
- final String breadcrumb, final Class<T> type, final T defaultObject ) {
- return defaultObject;
- }
-
- @Override
- @SuppressWarnings("rawtypes")
- public ObservableList loadObservableList(
- final String breadcrumb, final ObservableList defaultObservableList ) {
- return defaultObservableList;
- }
-
- @Override
- public <T> ObservableList<T> loadObservableList(
- final String breadcrumb,
- final Class<T> type,
- final ObservableList<T> defaultObservableList ) {
- return defaultObservableList;
- }
-
- @Override
- public boolean clearPreferences() {
- return false;
- }
-
- @Override
- public Preferences getPreferences() {
- return null;
- }
-}
src/main/java/com/keenwrite/preferences/XmlStore.java
+package com.keenwrite.preferences;
+
+import com.keenwrite.dom.DocumentParser;
+import javafx.beans.property.ListProperty;
+import javafx.beans.property.SetProperty;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathExpression;
+import javax.xml.xpath.XPathExpressionException;
+import java.io.File;
+import java.util.*;
+import java.util.Map.Entry;
+import java.util.function.Consumer;
+
+import static javax.xml.xpath.XPathConstants.NODE;
+
+/**
+ * Responsible for managing XML documents, which includes reading, writing,
+ * retrieving, and setting elements. This is an alternative to Apache
+ * Commons Configuration, JAXB, and Jackson. All of them are heavyweight and
+ * the latter are difficult to use with dynamic data (because they require
+ * annotations).
+ */
+public class XmlStore {
+ private static final String SEPARATOR = "/";
+
+ private final Document mDocument;
+ private final String mRoot;
+
+ /**
+ * Default constructor that creates an empty document with no root-level
+ * element. This is meant as a stub for testing.
+ */
+ public XmlStore() {
+ this( DocumentParser.newDocument(), "" );
+ }
+
+ /**
+ * Loads the given configuration file into a document object model.
+ * Clients of this class can set and retrieve elements via the requisite
+ * access methods.
+ *
+ * @param config File containing persistent user preferences.
+ * @param root Name of the root element (empty, but never {@code null}).
+ */
+ public XmlStore( final File config, final String root ) {
+ this( load( config ), root );
+ }
+
+ private XmlStore( final Document document, final String root ) {
+ assert document != null;
+ assert root != null;
+
+ mDocument = document;
+ mRoot = root;
+ }
+
+ private static Document load( final File config ) {
+ try {
+ return DocumentParser.parse( config );
+ } catch( final Exception ignored ) {
+ return DocumentParser.newDocument();
+ }
+ }
+
+ /**
+ * Returns the document value associated with the given key name.
+ *
+ * @param key {@link Key} name to retrieve.
+ * @return The value associated with the key, or the empty string if the
+ * key name could not be compiled.
+ */
+ public String getValue( final Key key ) {
+ assert key != null;
+
+ try {
+ final var xpath = toXPath( key );
+ final var expr = DocumentParser.compile( xpath );
+ return expr.evaluate( mDocument );
+ } catch( final XPathExpressionException ignored ) {
+ // This exception is a programming error; return a default value.
+ return "";
+ }
+ }
+
+ /**
+ * Returns a set of document values associated with the given key name. This
+ * is suitable for basic sets, such as:
+ * <pre>
+ * {@code
+ * <recent>
+ * <file>/tmp/filename.txt</file>
+ * <file>/home/username/document.md</file>
+ * <file>/usr/local/share/app/conf/help.Rmd</file>
+ * </recent>}
+ * </pre>
+ * <p>
+ * The {@code file} element name can be ignored.
+ *
+ * @param key {@link Key} name to retrieve.
+ * @return The values associated with the key, or an empty set if none found.
+ */
+ public Set<String> getSet( final Key key ) {
+ assert key != null;
+
+ final var set = new LinkedHashSet<String>();
+
+ visit( key, node -> set.add( node.getTextContent() ) );
+
+ return set;
+ }
+
+ /**
+ * Returns a map of name/value pairs associated with the given key name.
+ * This is suitable for mapped values, such as:
+ * <pre>
+ * {@code
+ * <meta>
+ * <title>{{book.title}}</title>
+ * <author>{{book.author}}</author>
+ * <date>{{book.publish.date}}</date>
+ * </meta>}
+ * </pre>
+ * <p>
+ * The element names under the {@code meta} node must be preserved along
+ * with their values. Resolving the values based on the variable definitions
+ * (in moustache syntax) is not a responsibility of this class.
+ *
+ * @param key {@link Key} name to retrieve (e.g., {@code meta}).
+ * @return A map of element names to element values, or an empty map if
+ * none found.
+ */
+ public Map<String, String> getMap( final Key key ) {
+ assert key != null;
+
+ // Create a new key that will match all child nodes under the given key,
+ // extracting each element as a name/value pair for the resulting map.
+ final var all = Key.key( key, "*" );
+ final var map = new LinkedHashMap<String, String>();
+
+ visit( all, node -> map.put( node.getNodeName(), node.getTextContent() ) );
+
+ return map;
+ }
+
+ public void save( final File config ) {
+ System.out.println( "SAVE TO: " + config );
+ System.out.println( DocumentParser.toString( mDocument ) );
+ }
+
+ public void setValue( final Key key, final String value ) {
+ assert key != null;
+ assert value != null;
+
+ try {
+ final var node = upsert( key, mDocument );
+
+ node.setTextContent( value );
+ } catch( final XPathExpressionException ignored ) {}
+ }
+
+ public void setSet( final Key key, final SetProperty<?> set ) {
+ assert key != null;
+ assert set != null;
+
+ try {
+ final var node = upsert( key, mDocument );
+
+ // Add child nodes and values.
+
+ System.out.printf( "%s = %s%n", key, set );
+ } catch( final XPathExpressionException ignored ) {}
+ }
+
+ /**
+ * @param key The application key representing a user preference.
+ * @param list List of {@link Entry} items.
+ */
+ public void setMap( final Key key, final ListProperty<?> list ) {
+ assert key != null;
+ assert list != null;
+
+ for( final var item : list ) {
+ if( item instanceof Entry entry ) {
+ try {
+ final var child = Key.key( key, entry.getKey().toString() );
+ final var node = upsert( child, mDocument );
+
+ node.setTextContent( entry.getValue().toString() );
+ } catch( final XPathExpressionException ignored ) {}
+ }
+ }
+ }
+
+ /**
+ * Finds the element in the document represented by the given {@link Key}.
+ * If no element is found then the full path to the element is created.
+ *
+ * @param key The application key representing a user preference.
+ * @param doc The document that may contain an xpath for the {@link Key}.
+ * @return The existing or new element.
+ */
+ private Node upsert( final Key key, final Document doc )
+ throws XPathExpressionException {
+ final var missing = new Stack<Key>();
+ Key visitor = key;
+ Node parent = null;
+
+ do {
+ final var xpath = toXPath( visitor );
+ final var expr = DocumentParser.compile( xpath );
+ final var element = expr.evaluate( doc, NODE );
+
+ // If an element exists on the first iteration, return it.
+ if( element instanceof Node node ) {
+ if( missing.isEmpty() ) {
+ return node;
+ }
+
+ parent = node;
+ }
+ else {
+ // Track the number of elements in the hierarchy that don't exist.
+ missing.push( visitor );
+
+ // Attempt to find the parent xpath in the document.
+ visitor = visitor.parent();
+ }
+ }
+ while( visitor.hasParent() && parent == null );
+
+ // If the document is empty, start creating nodes at the document root.
+ if( parent == null ) {
+ parent = doc.getDocumentElement();
+ }
+
+ // Create the hierarchy.
+ while( !missing.isEmpty() ) {
+ visitor = missing.pop();
+
+ final var child = doc.createElement( visitor.name() );
+ parent.appendChild( child );
+ parent = child;
+ }
+
+ return parent;
+ }
+
+ /**
+ * Abstraction for functionality that requires iterating over multiple
+ * nodes under a particular xpath.
+ *
+ * @param key {@link #toXPath(Key) Compiled} into an {@link XPath}.
+ * @param consumer Accepts each node that matches the {@link XPath}.
+ */
+ private void visit( final Key key, final Consumer<Node> consumer ) {
+ try {
+ final var xpath = toXPath( key );
+ DocumentParser.visit( mDocument, xpath, consumer );
+ } catch( final XPathExpressionException ignored ) {
+ // Programming error. Maybe triggered loading a previous config version?
+ }
+ }
+
+ /**
+ * Creates an {@link XPathExpression} value based on the given {@link Key}.
+ *
+ * @param key The {@link Key} to convert to an xpath string.
+ * @return The given {@link Key} compiled into an {@link XPathExpression}.
+ * @throws XPathExpressionException Could not compile the {@link Key}.
+ */
+ private StringBuilder toXPath( final Key key )
+ throws XPathExpressionException {
+ final var sb = new StringBuilder( 128 );
+
+ key.walk( sb::append, SEPARATOR );
+ sb.insert( 0, SEPARATOR );
+
+ if( !mRoot.isBlank() ) {
+ sb.insert( 0, SEPARATOR + mRoot );
+ }
+
+ return sb;
+ }
+
+ /**
+ * Pretty-prints the XML document into a string. Meant to be used for
+ * debugging. To save the configuration, see {@link #save(File)}.
+ *
+ * @return The document in a well-formed, indented, string format.
+ */
+ @Override
+ public String toString() {
+ return DocumentParser.toString( mDocument );
+ }
+}