Dave Jarvis' Repositories

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

Add preference for user-defined fonts

AuthorDaveJarvis <email>
Date2021-01-01 18:53:00 GMT-0800
Commitb800bf3f6d8fb97746e51432de5adcf7a09dab29
Parent84ac84a
Delta653 lines added, 370 lines removed, 283-line increase
src/main/resources/com/keenwrite/messages.properties
Main.status.image.request.success=Detected content type ''{0}''
+Main.status.font.search.missing=No font name starting with ''{0}'' was found
+
# ########################################################################
# Search Bar
workspace.ui.font=Fonts
+workspace.ui.font.editor.name=Editor Font Name
+workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
+workspace.ui.font.editor.name.title=Name
workspace.ui.font.editor.size=Editor Font Size
workspace.ui.font.editor.size.desc=Text editor font size.
workspace.ui.font.editor.size.title=Points
+workspace.ui.font.preview.name=Preview Font Name
+workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
+workspace.ui.font.preview.name.title=Name
workspace.ui.font.preview.size=Preview Font Size
workspace.ui.font.preview.size.desc=Preview pane font size.
workspace.ui.font.preview.size.title=Points
-workspace.ui.font.locale=Locale
-workspace.ui.font.locale.desc=Character set for editing and previewing.
-workspace.ui.font.locale.title=Language
+
+workspace.language=Language
+workspace.language.locale=Internationalization
+workspace.language.locale.desc=Language for application and HTML export.
+workspace.language.locale.title=Locale
# ########################################################################
src/main/java/com/keenwrite/preferences/PreferencesController.java
import java.io.File;
+import static com.dlsc.formsfx.model.structure.Field.ofStringType;
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
import static com.keenwrite.Constants.ICON_DIALOG;
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 );
}
* @return A new instance of preferences for users to edit.
*/
- @SuppressWarnings( "unchecked" )
private PreferencesFx createPreferencesFx() {
- final Setting<StringField, StringProperty> scriptSetting =
- Setting.of( "Script", stringProperty( KEY_R_SCRIPT ) );
- final var field = scriptSetting.getElement();
- field.multiline( true );
-
return PreferencesFx.of(
new XmlStorageHandler(),
get( KEY_R_SCRIPT ),
Setting.of( label( KEY_R_SCRIPT ) ),
- scriptSetting
+ createScriptSetting()
),
Group.of(
get( KEY_UI_FONT ),
Group.of(
- get( KEY_UI_FONT_PREVIEW_SIZE ),
- Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
- Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
- doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) )
+ get( KEY_UI_FONT_EDITOR_NAME ),
+ 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 ) )
),
Group.of(
get( KEY_UI_FONT_EDITOR_SIZE ),
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_LOCALE ),
- Setting.of( label( KEY_UI_FONT_LOCALE ) ),
- Setting.of( title( KEY_UI_FONT_LOCALE ),
+ get( KEY_UI_FONT_PREVIEW_NAME ),
+ 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 ) )
+ ),
+ Group.of(
+ get( KEY_UI_FONT_PREVIEW_SIZE ),
+ Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
+ Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
+ doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) )
+ )
+ ),
+ Category.of(
+ get( KEY_LANGUAGE ),
+ Group.of(
+ get( KEY_LANG_LOCALE ),
+ Setting.of( label( KEY_LANG_LOCALE ) ),
+ Setting.of( title( KEY_LANG_LOCALE ),
localeListProperty(),
- localeProperty( KEY_UI_FONT_LOCALE ) )
+ localeProperty( KEY_LANG_LOCALE ) )
)
)
).instantPersistent( false ).dialogIcon( ICON_DIALOG );
+ }
+
+ @SuppressWarnings( "unchecked" )
+ private Setting<StringField, StringProperty> createScriptSetting() {
+ final Setting<StringField, StringProperty> scriptSetting =
+ Setting.of( "Script", stringProperty( KEY_R_SCRIPT ) );
+ final var field = scriptSetting.getElement();
+ field.multiline( true );
+
+ return scriptSetting;
}
src/main/java/com/keenwrite/preferences/SimpleFontControl.java
+/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.preferences;
+
+import com.dlsc.formsfx.model.structure.StringField;
+import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.scene.control.Button;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.text.Font;
+import javafx.stage.Stage;
+import org.controlsfx.dialog.FontSelectorDialog;
+
+import static com.keenwrite.Constants.ICON_DIALOG;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static java.lang.System.currentTimeMillis;
+import static javafx.geometry.Pos.CENTER_LEFT;
+import static javafx.scene.control.ButtonType.CANCEL;
+import static javafx.scene.control.ButtonType.OK;
+import static javafx.scene.input.KeyCode.ENTER;
+import static javafx.scene.input.KeyCode.ESCAPE;
+import static javafx.scene.layout.Priority.ALWAYS;
+import static javafx.scene.text.Font.font;
+import static javafx.scene.text.Font.getDefault;
+
+/**
+ * Responsible for provide users the ability to select a font using a friendly
+ * font dialog.
+ */
+public class SimpleFontControl extends SimpleControl<StringField, StackPane> {
+ private final Button mButton = new Button();
+ private final String mButtonText;
+ private final DoubleProperty mFontSize = new SimpleDoubleProperty();
+ private final TextField mFontName = new TextField();
+
+ public SimpleFontControl( final String buttonText ) {
+ mButtonText = buttonText;
+ }
+
+ @Override
+ public void initializeParts() {
+ super.initializeParts();
+
+ mFontName.setText( field.getValue() );
+ mFontName.setPromptText( field.placeholderProperty().getValue() );
+
+ final var fieldProperty = field.valueProperty();
+ if( fieldProperty.get().equals( "null" ) ) {
+ fieldProperty.set( "" );
+ }
+
+ mButton.setText( mButtonText );
+ mButton.setOnAction( event -> {
+ final var selected = !fieldProperty.get().trim().isEmpty();
+ var initialFont = getDefault();
+ if( selected ) {
+ final var previousValue = fieldProperty.get();
+ initialFont = font( previousValue );
+ }
+
+ createFontSelectorDialog( initialFont )
+ .showAndWait()
+ .ifPresent( ( font ) -> {
+ mFontName.setText( font.getName() );
+ mFontSize.set( font.getSize() );
+ } );
+ } );
+
+ node = new StackPane();
+ }
+
+ @Override
+ public void layoutParts() {
+ node.getStyleClass().add( "simple-text-control" );
+ fieldLabel.getStyleClass().addAll( field.getStyleClass() );
+ fieldLabel.getStyleClass().add( "read-only-label" );
+
+ final var box = new HBox();
+ HBox.setHgrow( mFontName, ALWAYS );
+ box.setAlignment( CENTER_LEFT );
+ box.getChildren().addAll( fieldLabel, mFontName, mButton );
+
+ node.getChildren().add( box );
+ }
+
+ @Override
+ public void setupBindings() {
+ super.setupBindings();
+ mFontName.textProperty().bindBidirectional( field.userInputProperty() );
+ }
+
+ public DoubleProperty fontSizeProperty() {
+ return mFontSize;
+ }
+
+ /**
+ * Creates a dialog that displays a list of available font families,
+ * sizes, and a button for font selection.
+ *
+ * @param font The default font to select initially.
+ * @return A dialog to help the user select a different {@link Font}.
+ */
+ private FontSelectorDialog createFontSelectorDialog( final Font font ) {
+ final var dialog = new FontSelectorDialog( font );
+ final var pane = dialog.getDialogPane();
+ final var buttonOk = ((Button) pane.lookupButton( OK ));
+ final var buttonCancel = ((Button) pane.lookupButton( CANCEL ));
+
+ buttonOk.setDefaultButton( true );
+ buttonCancel.setCancelButton( true );
+ pane.setOnKeyReleased( ( keyEvent ) -> {
+ final var code = keyEvent.getCode();
+ if( code == ENTER ) {
+ buttonOk.fire();
+ }
+ else if( code == ESCAPE ) {
+ buttonCancel.fire();
+ }
+ } );
+
+ final var stage = (Stage) pane.getScene().getWindow();
+ stage.getIcons().add( ICON_DIALOG );
+
+ final var frontPanel = (Region) pane.getContent();
+ for( final var node : frontPanel.getChildrenUnmodifiable() ) {
+ if( node instanceof ListView ) {
+ final var listView = (ListView<?>) node;
+ final var handler = new ListViewHandler<>( listView );
+ listView.setOnKeyPressed( handler::handle );
+ }
+ }
+
+ return dialog;
+ }
+
+ /**
+ * Responsible for handling key presses when selecting a font. Based on
+ * <a href="https://stackoverflow.com/a/43604223/59087">Martin Široký</a>'s
+ * answer.
+ *
+ * @param <T> The type of {@link ListView} to search.
+ */
+ private static final class ListViewHandler<T> {
+ /**
+ * Amount of time to wait between key presses that typing a subsequent
+ * key is considered part of the same search, in milliseconds.
+ */
+ private static final int RESET_DELAY_MS = 1250;
+
+ private String mNeedle = "";
+ private int mSearchSkip = 0;
+ private long mLastTyped = currentTimeMillis();
+ private final ListView<T> mHaystack;
+
+ private ListViewHandler( final ListView<T> listView ) {
+ mHaystack = listView;
+ }
+
+ private void handle( final KeyEvent key ) {
+ var ch = key.getText();
+ final var code = key.getCode();
+
+ if( ch == null || ch.isEmpty() || code == ESCAPE || code == ENTER ) {
+ return;
+ }
+
+ ch = ch.toUpperCase();
+
+ if( mNeedle.equals( ch ) ) {
+ mSearchSkip++;
+ }
+ else {
+ mNeedle = currentTimeMillis() - mLastTyped > RESET_DELAY_MS
+ ? ch : mNeedle + ch;
+ }
+
+ mLastTyped = currentTimeMillis();
+
+ boolean found = false;
+ int skipped = 0;
+
+ for( final T item : mHaystack.getItems() ) {
+ final var straw = item.toString().toUpperCase();
+
+ if( straw.startsWith( mNeedle ) ) {
+ if( mSearchSkip > skipped ) {
+ skipped++;
+ continue;
+ }
+
+ mHaystack.getSelectionModel().select( item );
+ final int index = mHaystack.getSelectionModel().getSelectedIndex();
+ mHaystack.getFocusModel().focus( index );
+ mHaystack.scrollTo( index );
+ found = true;
+ break;
+ }
+ }
+
+ if( !found ) {
+ clue( "Main.status.font.search.missing", mNeedle );
+ mSearchSkip = 0;
+ }
+ }
+ }
+}
src/main/java/com/keenwrite/preferences/Workspace.java
import javafx.application.Platform;
import javafx.beans.property.*;
-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.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.Map;
-import java.util.Set;
-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.Constants.*;
-import static com.keenwrite.Launcher.getVersion;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.preferences.Key.key;
-import static java.util.Map.entry;
-import static javafx.application.Platform.runLater;
-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 a file name ({@link File#isDirectory()} is true)
- * .</dd>
- * </dl>
- */
-public class Workspace {
- private static final Key KEY_ROOT = key( "workspace" );
-
- public static final Key KEY_META = key( KEY_ROOT, "meta" );
- public static final Key KEY_META_NAME = key( KEY_META, "name" );
- public static final Key KEY_META_VERSION = key( KEY_META, "version" );
-
- public static final Key KEY_R = key( KEY_ROOT, "r" );
- public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
- public static final Key KEY_R_DIR = key( KEY_R, "dir" );
- public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
- public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
- public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
-
- public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
- public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
- public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
-
- public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
- public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
- public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
- public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
- public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
-
- //@formatter:off
- public static final Key KEY_UI = key( KEY_ROOT, "ui" );
-
- public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
- public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
- public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
- public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
-
- public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
- public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
-
- public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
- public static final Key KEY_UI_FONT_LOCALE = key( KEY_UI_FONT, "locale" );
- public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
- public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
- public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
- public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
-
- public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
- public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
- public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
- public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
- public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
- public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
- public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
-
- private final Map<Key, Property<?>> VALUES = Map.ofEntries(
- entry( KEY_META_VERSION, new SimpleStringProperty( getVersion() ) ),
- entry( KEY_META_NAME, new SimpleStringProperty( "default" ) ),
-
- entry( KEY_R_SCRIPT, new SimpleStringProperty( "" ) ),
- entry( KEY_R_DIR, new FileProperty( USER_DIRECTORY ) ),
- entry( KEY_R_DELIM_BEGAN, new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
- entry( KEY_R_DELIM_ENDED, new SimpleStringProperty( R_DELIM_ENDED_DEFAULT ) ),
-
- entry( KEY_IMAGES_DIR, new FileProperty( USER_DIRECTORY ) ),
- entry( KEY_IMAGES_ORDER, new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ) ),
-
- entry( KEY_DEF_PATH, new FileProperty( DEFINITION_DEFAULT ) ),
- entry( KEY_DEF_DELIM_BEGAN, new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
- entry( KEY_DEF_DELIM_ENDED, new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
-
- entry( KEY_UI_RECENT_DIR, new FileProperty( USER_DIRECTORY ) ),
- entry( KEY_UI_RECENT_DOCUMENT, new FileProperty( DOCUMENT_DEFAULT ) ),
- entry( KEY_UI_RECENT_DEFINITION, new FileProperty( DEFINITION_DEFAULT ) ),
-
- entry( KEY_UI_FONT_LOCALE, new LocaleProperty( LOCALE_DEFAULT ) ),
- entry( KEY_UI_FONT_EDITOR_SIZE, new SimpleDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
- entry( KEY_UI_FONT_PREVIEW_SIZE, new SimpleDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
-
- entry( KEY_UI_WINDOW_X, new SimpleDoubleProperty( WINDOW_X_DEFAULT ) ),
- entry( KEY_UI_WINDOW_Y, new SimpleDoubleProperty( WINDOW_Y_DEFAULT ) ),
- entry( KEY_UI_WINDOW_W, new SimpleDoubleProperty( WINDOW_W_DEFAULT ) ),
- entry( KEY_UI_WINDOW_H, new SimpleDoubleProperty( WINDOW_H_DEFAULT ) ),
- entry( KEY_UI_WINDOW_MAX, new SimpleBooleanProperty() ),
- entry( KEY_UI_WINDOW_FULL, new SimpleBooleanProperty() )
- );
- //@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,
- 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,
- new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
- )
- );
-
- /**
- * 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();
- }
-
- /**
- * 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 ) {
- // The type that goes into the map must come out.
- return (U) VALUES.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 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 ) {
- // The type that goes into the map must come out.
- return (SetProperty<T>) SETS.get( key );
- }
-
- /**
- * 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}.
- */
- public boolean toBoolean( final Key key ) {
- return (Boolean) valuesProperty( key ).getValue();
- }
-
- /**
- * 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}.
- */
- public double toDouble( final Key key ) {
- return (Double) valuesProperty( key ).getValue();
- }
-
- public File toFile( final Key key ) {
- return fileProperty( key ).get();
- }
-
- public String toString( final Key key ) {
- return stringProperty( key ).get();
- }
-
- public Tokens toTokens( final Key began, final Key ended ) {
- return new Tokens( stringProperty( began ), stringProperty( ended ) );
- }
-
- @SuppressWarnings( "SameParameterValue" )
- public DoubleProperty doubleProperty( final Key key ) {
- 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 ) {
- return valuesProperty( key );
- }
-
- public LocaleProperty localeProperty( final Key key ) {
- return valuesProperty( key );
- }
-
- public StringProperty stringProperty( final Key key ) {
- return valuesProperty( key );
- }
-
- public void loadValueKeys( final Consumer<Key> consumer ) {
- VALUES.keySet().forEach( consumer );
- }
-
- public void loadSetKeys( final Consumer<Key> consumer ) {
- SETS.keySet().forEach( consumer );
- }
-
- /**
- * Calls the given consumer for all single-value keys. For lists, see
- * {@link #saveSets(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 list.
- */
- public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
- SETS.forEach( consumer );
- }
-
- /**
- * 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 );
- }
- } )
- );
- }
-
- /**
- * 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 );
-
- saveValues( ( key, property ) ->
- config.setProperty( key.toString(), marshall( property ) )
- );
-
- saveSets(
- ( key, set ) -> {
- final var keyName = key.toString();
- set.forEach( ( value ) -> config.addProperty( keyName, value ) );
- }
- );
+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.util.*;
+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.Constants.*;
+import static com.keenwrite.Launcher.getVersion;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.preferences.Key.key;
+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 a file name ({@link File#isDirectory()} is true)
+ * .</dd>
+ * </dl>
+ */
+public class Workspace {
+ private static final Key KEY_ROOT = key( "workspace" );
+
+ public static final Key KEY_META = key( KEY_ROOT, "meta" );
+ public static final Key KEY_META_NAME = key( KEY_META, "name" );
+ public static final Key KEY_META_VERSION = key( KEY_META, "version" );
+
+ public static final Key KEY_R = key( KEY_ROOT, "r" );
+ public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
+ public static final Key KEY_R_DIR = key( KEY_R, "dir" );
+ public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
+ public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
+ public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
+
+ public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
+ public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
+ public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
+
+ public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
+ public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
+ public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
+ public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
+ public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
+
+ //@formatter:off
+ public static final Key KEY_UI = key( KEY_ROOT, "ui" );
+
+ public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
+ public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
+ public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
+ public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
+
+ public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
+ public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
+
+ public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
+ public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
+ public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
+ public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
+ public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
+ public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
+ public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
+
+ public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
+ public static final Key KEY_LANG_LOCALE = key( KEY_LANGUAGE, "locale" );
+
+ public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
+ public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
+ public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
+ public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
+ public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
+ public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
+ public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
+
+ private final Map<Key, Property<?>> VALUES = Map.ofEntries(
+ entry( KEY_META_VERSION, new SimpleStringProperty( getVersion() ) ),
+ entry( KEY_META_NAME, new SimpleStringProperty( "default" ) ),
+
+ entry( KEY_R_SCRIPT, new SimpleStringProperty( "" ) ),
+ entry( KEY_R_DIR, new FileProperty( USER_DIRECTORY ) ),
+ entry( KEY_R_DELIM_BEGAN, new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
+ entry( KEY_R_DELIM_ENDED, new SimpleStringProperty( R_DELIM_ENDED_DEFAULT ) ),
+
+ entry( KEY_IMAGES_DIR, new FileProperty( USER_DIRECTORY ) ),
+ entry( KEY_IMAGES_ORDER, new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ) ),
+
+ entry( KEY_DEF_PATH, new FileProperty( DEFINITION_DEFAULT ) ),
+ entry( KEY_DEF_DELIM_BEGAN, new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
+ entry( KEY_DEF_DELIM_ENDED, new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
+
+ entry( KEY_UI_RECENT_DIR, new FileProperty( USER_DIRECTORY ) ),
+ entry( KEY_UI_RECENT_DOCUMENT, new FileProperty( DOCUMENT_DEFAULT ) ),
+ entry( KEY_UI_RECENT_DEFINITION, new FileProperty( DEFINITION_DEFAULT ) ),
+
+ entry( KEY_LANG_LOCALE, new LocaleProperty( LOCALE_DEFAULT ) ),
+ entry( KEY_UI_FONT_EDITOR_NAME, new SimpleStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
+ entry( KEY_UI_FONT_EDITOR_SIZE, new SimpleDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_NAME, new SimpleStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
+ entry( KEY_UI_FONT_PREVIEW_SIZE, new SimpleDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
+
+ entry( KEY_UI_WINDOW_X, new SimpleDoubleProperty( WINDOW_X_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_Y, new SimpleDoubleProperty( WINDOW_Y_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_W, new SimpleDoubleProperty( WINDOW_W_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_H, new SimpleDoubleProperty( WINDOW_H_DEFAULT ) ),
+ entry( KEY_UI_WINDOW_MAX, new SimpleBooleanProperty() ),
+ entry( KEY_UI_WINDOW_FULL, new SimpleBooleanProperty() )
+ );
+ //@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,
+ 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,
+ new SimpleSetProperty<>( observableSet( new HashSet<>() ) )
+ )
+ );
+
+ /**
+ * 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();
+ }
+
+ /**
+ * 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 ) );
+ }
+
+ /**
+ * 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 ) {
+ // The type that goes into the map must come out.
+ return (U) VALUES.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 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 ) {
+ // The type that goes into the map must come out.
+ return (SetProperty<T>) SETS.get( key );
+ }
+
+ /**
+ * 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}.
+ */
+ public boolean toBoolean( final Key key ) {
+ return (Boolean) valuesProperty( key ).getValue();
+ }
+
+ /**
+ * 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}.
+ */
+ public double toDouble( final Key key ) {
+ return (Double) valuesProperty( key ).getValue();
+ }
+
+ public File toFile( final Key key ) {
+ return fileProperty( key ).get();
+ }
+
+ public String toString( final Key key ) {
+ return stringProperty( key ).get();
+ }
+
+ public Tokens toTokens( final Key began, final Key ended ) {
+ return new Tokens( stringProperty( began ), stringProperty( ended ) );
+ }
+
+ @SuppressWarnings( "SameParameterValue" )
+ public DoubleProperty doubleProperty( final Key key ) {
+ 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 ) {
+ return valuesProperty( key );
+ }
+
+ public LocaleProperty localeProperty( final Key key ) {
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the language locale setting for the {@link #KEY_LANG_LOCALE} key.
+ *
+ * @return The user's current locale setting.
+ */
+ public Locale getLocale() {
+ return localeProperty( KEY_LANG_LOCALE ).toLocale();
+ }
+
+ public StringProperty stringProperty( final Key key ) {
+ return valuesProperty( key );
+ }
+
+ public void loadValueKeys( final Consumer<Key> consumer ) {
+ VALUES.keySet().forEach( consumer );
+ }
+
+ public void loadSetKeys( final Consumer<Key> consumer ) {
+ SETS.keySet().forEach( consumer );
+ }
+
+ /**
+ * Calls the given consumer for all single-value keys. For lists, see
+ * {@link #saveSets(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 list.
+ */
+ public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) {
+ SETS.forEach( consumer );
+ }
+
+ /**
+ * 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 );
+ }
+ } )
+ );
+ }
+
+ /**
+ * 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 ) );
+ } );
new FileHandler( config ).save( FILE_PREFERENCES );
} catch( final Exception ex ) {