| Author | DaveJarvis <email> |
|---|---|
| Date | 2021-12-27 13:02:01 GMT-0800 |
| Commit | d14acf8214b5f1b0602bfc0221674d91a5d153e3 |
| Parent | 97661c4 |
| Delta | 1495 lines added, 1262 lines removed, 233-line increase |
| 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 ) ); | ||
| + } | ||
| } | ||
| 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(); | ||
| } | ||
| } | ||
| 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 ); | ||
| } | ||
| } |
| 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() {} | ||
| } | ||
| +/* 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; | ||
| + } | ||
| +} | ||
| 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 ); | ||
| } | ||
| 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 ); | ||
| } | ||
| } |
| -/* 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; | ||
| - } | ||
| -} | ||
| +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 ); | ||
| + } | ||
| +} | ||