Dave Jarvis' Repositories

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

Add more document metadata

AuthorDaveJarvis <email>
Date2021-04-16 20:19:48 GMT-0700
Commit84c8d4217f943f9185b6d3d0317eb3688dafe98d
Parent475714f
src/main/java/com/keenwrite/preferences/PreferencesController.java
Setting.of( title( KEY_DOC_AUTHOR ),
stringProperty( KEY_DOC_AUTHOR ) )
+ ),
+ 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 ) )
)
),
src/main/java/com/keenwrite/preferences/Workspace.java
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.Launcher.getVersion;
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.WorkspaceKeys.*;
-import static java.lang.System.getProperty;
-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 final class Workspace {
- //@formatter:off
- 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_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_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_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_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
-
- entry( KEY_TYPESET_CONTEXT_PATH, asFileProperty( USER_DIRECTORY ) ),
- entry( KEY_TYPESET_CONTEXT_ENV, asStringProperty( "" ) )
- );
- //@formatter:on
-
- private StringProperty asStringProperty( final String defaultValue ) {
- return new SimpleStringProperty( defaultValue );
- }
-
- private DoubleProperty asDoubleProperty( final double defaultValue ) {
- return new SimpleDoubleProperty( defaultValue );
- }
-
- private BooleanProperty asBooleanProperty() {
- return new SimpleBooleanProperty();
- }
-
- private FileProperty asFileProperty( final File defaultValue ) {
- return new FileProperty( defaultValue );
- }
-
- @SuppressWarnings( "SameParameterValue" )
- private SkinProperty asSkinProperty( final String defaultValue ) {
- return new SkinProperty( defaultValue );
- }
-
- @SuppressWarnings( "SameParameterValue" )
- private LocaleProperty asLocaleProperty( final Locale defaultValue ) {
- return new LocaleProperty( defaultValue );
- }
-
- /**
- * 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( 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 );
- }
-
- /**
- * 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 ObjectProperty<String> skinProperty( final Key key ) {
- return valuesProperty( key );
- }
-
- public LocaleProperty localeProperty( final Key key ) {
- return valuesProperty( key );
- }
-
- /**
- * Returns the language locale setting for the
- * {@link WorkspaceKeys#KEY_LANGUAGE_LOCALE} key.
- *
- * @return The user's current locale setting.
- */
- public Locale getLocale() {
- return localeProperty( KEY_LANGUAGE_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 ) {
- 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 propertyValue = valuesProperty( key );
- propertyValue.setValue( unmarshall( propertyValue, configValue ) );
- }
- } );
-
- loadSetKeys( ( key ) -> {
- final var configSet =
- new LinkedHashSet<>( config.getList( key.toString() ) );
- final var propertySet = setsProperty( key );
- propertySet.setValue( observableSet( configSet ) );
- } );
- } 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() );
+import java.time.Year;
+import java.time.ZonedDateTime;
+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.Launcher.getVersion;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.preferences.WorkspaceKeys.*;
+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 a file name ({@link File#isDirectory()} is true)
+ * .</dd>
+ * </dl>
+ */
+public final class Workspace {
+ //@formatter:off
+ 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_KEYWORDS, asStringProperty( "science, nature" ) ),
+ entry( KEY_DOC_COPYRIGHT, asStringProperty( getYear() ) ),
+ entry( KEY_DOC_DATE, asStringProperty( getDate() ) ),
+
+ 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_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_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_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
+
+ entry( KEY_TYPESET_CONTEXT_PATH, asFileProperty( USER_DIRECTORY ) ),
+ entry( KEY_TYPESET_CONTEXT_ENV, asStringProperty( "" ) )
+ );
+ //@formatter:on
+
+ private StringProperty asStringProperty( final String defaultValue ) {
+ return new SimpleStringProperty( defaultValue );
+ }
+
+ private DoubleProperty asDoubleProperty( final double defaultValue ) {
+ return new SimpleDoubleProperty( defaultValue );
+ }
+
+ private BooleanProperty asBooleanProperty() {
+ return new SimpleBooleanProperty();
+ }
+
+ private FileProperty asFileProperty( final File defaultValue ) {
+ return new FileProperty( defaultValue );
+ }
+
+ @SuppressWarnings( "SameParameterValue" )
+ private SkinProperty asSkinProperty( final String defaultValue ) {
+ return new SkinProperty( defaultValue );
+ }
+
+ @SuppressWarnings( "SameParameterValue" )
+ private LocaleProperty asLocaleProperty( final Locale defaultValue ) {
+ return new LocaleProperty( defaultValue );
+ }
+
+ /**
+ * 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( 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 );
+ }
+
+ /**
+ * 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 ObjectProperty<String> skinProperty( final Key key ) {
+ return valuesProperty( key );
+ }
+
+ public LocaleProperty localeProperty( final Key key ) {
+ return valuesProperty( key );
+ }
+
+ /**
+ * Returns the language locale setting for the
+ * {@link WorkspaceKeys#KEY_LANGUAGE_LOCALE} key.
+ *
+ * @return The user's current locale setting.
+ */
+ public Locale getLocale() {
+ return localeProperty( KEY_LANGUAGE_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 ) {
+ 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 propertyValue = valuesProperty( key );
+ propertyValue.setValue( unmarshall( propertyValue, configValue ) );
+ }
+ } );
+
+ loadSetKeys( ( key ) -> {
+ final var configSet =
+ new LinkedHashSet<>( config.getList( key.toString() ) );
+ final var propertySet = setsProperty( key );
+ propertySet.setValue( observableSet( configSet ) );
+ } );
+ } 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() );
+ }
+
+ private String getYear() {
+ return valueOf( Year.now().getValue() );
+ }
+
+ private String getDate() {
+ return ZonedDateTime.now().format( RFC_1123_DATE_TIME );
}
}
src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
public static final Key KEY_DOC_TITLE = key( KEY_DOC, "title" );
public static final Key KEY_DOC_AUTHOR = key( KEY_DOC, "author" );
+ public static final Key KEY_DOC_KEYWORDS = key( KEY_DOC, "keywords" );
+ public static final Key KEY_DOC_DATE = key( KEY_DOC, "date" );
+ public static final Key KEY_DOC_COPYRIGHT = key( KEY_DOC, "copyright" );
public static final Key KEY_R = key( KEY_ROOT, "r" );
src/main/java/com/keenwrite/preview/HtmlPreview.java
import com.keenwrite.events.ScrollLockEvent;
-import com.keenwrite.preferences.LocaleProperty;
-import com.keenwrite.preferences.Workspace;
-import javafx.beans.property.DoubleProperty;
-import javafx.beans.property.StringProperty;
-import javafx.embed.swing.SwingNode;
-import org.greenrobot.eventbus.Subscribe;
-import org.xhtmlrenderer.render.Box;
-import org.xhtmlrenderer.swing.SwingReplacedElementFactory;
-
-import javax.swing.*;
-import java.awt.*;
-import java.net.URL;
-import java.nio.file.Path;
-import java.util.Locale;
-
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.constants.Constants.*;
-import static com.keenwrite.events.Bus.register;
-import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.WorkspaceKeys.*;
-import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
-import static java.awt.BorderLayout.*;
-import static java.lang.Math.max;
-import static java.lang.String.format;
-import static java.lang.Thread.sleep;
-import static javafx.scene.CacheHint.SPEED;
-import static javax.swing.SwingUtilities.invokeLater;
-import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
-import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
-
-/**
- * Responsible for parsing an HTML document.
- */
-public final class HtmlPreview extends SwingNode {
-
- /**
- * The order is important: Swing factory will replace SVG images with
- * a blank image, which will cause the chained factory to cache the image
- * and exit. Instead, the SVG must execute first to rasterize the content.
- * Consequently, the chained factory must maintain insertion order.
- */
- private static final ChainedReplacedElementFactory FACTORY
- = new ChainedReplacedElementFactory(
- new SvgReplacedElementFactory(),
- new SwingReplacedElementFactory()
- );
-
- /**
- * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
- */
- private static final String HTML_STYLESHEET =
- "<link rel='stylesheet' href='%s'>";
-
- private static final String HTML_BASE =
- "<base href='%s'>";
-
- /**
- * Render CSS using points (pt) not pixels (px) to reduce the chance of
- * poor rendering. The {@link #generateHead()} method fills placeholders.
- * When the user has not set a locale, only one stylesheet is added to
- * the document. In order, the placeholders are as follows:
- * <ol>
- * <li>%s --- language</li>
- * <li>%s --- default stylesheet</li>
- * <li>%s --- language-specific stylesheet</li>
- * <li>%s --- font family</li>
- * <li>%d --- font size (must be pixels, not points due to bug)</li>
- * <li>%s --- base href</li>
- * </p>
- */
- private static final String HTML_HEAD =
- """
- <!doctype html>
- <html lang='%s'><head><title> </title><meta charset='utf-8'>
- %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
- """;
-
- private static final String HTML_TAIL = "</body></html>";
-
- private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
-
- /**
- * Reusing this buffer prevents repetitious memory re-allocations.
- */
- private final StringBuilder mDocument = new StringBuilder( 65536 );
-
- private HtmlPanel mView;
- private JScrollPane mScrollPane;
- private String mBaseUriPath = "";
- private String mHead = "";
-
- private volatile boolean mLocked;
- private final JButton mScrollLockButton = new JButton();
-
- private final Workspace mWorkspace;
-
- /**
- * Creates a new preview pane that can scroll to the caret position within the
- * document.
- *
- * @param workspace Contains locale and font size information.
- */
- public HtmlPreview( final Workspace workspace ) {
- mWorkspace = workspace;
-
- // Attempts to prevent a flash of black un-styled content upon load.
- setStyle( "-fx-background-color: white;" );
-
- invokeLater( () -> {
- mHead = generateHead();
- mView = new HtmlPanel();
- mScrollPane = new JScrollPane( mView );
- final var verticalBar = mScrollPane.getVerticalScrollBar();
- final var verticalPanel = new JPanel( new BorderLayout() );
-
- mScrollLockButton.setFont( getIconFont( 14 ) );
- mScrollLockButton.setText( getLockText( mLocked ) );
- mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
- mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
-
- verticalPanel.add( verticalBar, CENTER );
- verticalPanel.add( mScrollLockButton, PAGE_END );
-
- final var wrapper = new JPanel( new BorderLayout() );
- wrapper.add( mScrollPane, CENTER );
- wrapper.add( verticalPanel, LINE_END );
-
- // Enabling the cache attempts to prevent black flashes when resizing.
- setCache( true );
- setCacheHint( SPEED );
- setContent( wrapper );
-
- final var context = mView.getSharedContext();
- final var textRenderer = context.getTextRenderer();
- context.setReplacedElementFactory( FACTORY );
- textRenderer.setSmoothingThreshold( 0 );
-
- localeProperty().addListener( ( c, o, n ) -> rerender() );
- fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
- fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
- } );
-
- register( this );
- }
-
- @Subscribe
- public void handle( final ScrollLockEvent event ) {
- mLocked = event.isLocked();
- invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
- }
-
- /**
- * Updates the internal HTML source shown in the preview pane.
- *
- * @param html The new HTML document to display.
- */
- public void render( final String html ) {
- mView.render( decorate( html ), getBaseUri() );
- }
-
- /**
- * Clears the caches then re-renders the content.
- */
- public void refresh() {
- FACTORY.clearCache();
- rerender();
- }
-
- /**
- * Recomputes the HTML head then renders the document.
- */
- private void rerender() {
- mHead = generateHead();
- render( mDocument.toString() );
- }
-
- /**
- * Attaches the HTML head prefix and HTML tail suffix to the given HTML
- * string.
- *
- * @param html The HTML to adorn with opening and closing tags.
- * @return A complete HTML document, ready for rendering.
- */
- private String decorate( final String html ) {
- mDocument.setLength( 0 );
- mDocument.append( html );
-
- // Head and tail must be separate from document due to re-rendering.
- return mHead + mDocument.toString() + HTML_TAIL;
- }
-
- /**
- * Called when settings are changed that affect the HTML document preamble.
- * This is a minor performance optimization to avoid generating the head
- * each time that the document itself changes.
- *
- * @return A new doctype and HTML {@code head} element.
- */
- private String generateHead() {
- final var locale = getLocale();
- final var url = toUrl( locale );
- final var base = getBaseUri();
-
- // Point sizes are converted to pixels because of a rendering bug.
- return format(
- HTML_HEAD,
- locale.getLanguage(),
- format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ),
- url == null ? "" : format( HTML_STYLESHEET, url ),
- getFontFamily(),
- toPixels( getFontSize() ),
- base.isBlank() ? "" : format( HTML_BASE, base )
- );
- }
-
- /**
- * Clears the preview pane by rendering an empty string.
- */
- public void clear() {
- render( "" );
- }
-
- /**
- * Sets the base URI to the containing directory the file being edited.
- *
- * @param path The path to the file being edited.
- */
- public void setBaseUri( final Path path ) {
- final var parent = path.getParent();
- mBaseUriPath = parent == null ? "" : parent.toUri().toString();
- }
-
- /**
- * Scrolls to the closest element matching the given identifier without
- * waiting for the document to be ready.
- *
- * @param id Scroll the preview pane to this unique paragraph identifier.
- */
- public void scrollTo( final String id ) {
- if( mLocked ) {
- return;
- }
-
- invokeLater( () -> {
- int iter = 0;
- Box box = null;
-
- while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
- try {
- sleep( 10 );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- scrollTo( box );
- } );
- }
-
- /**
- * Scrolls to the location specified by the {@link Box} that corresponds
- * to a point somewhere in the preview pane. If there is no caret, then
- * this will not change the scroll position. Changing the scroll position
- * to the top if the {@link Box} instance is {@code null} will result in
- * jumping around a lot and inconsistent synchronization issues.
- *
- * @param box The rectangular region containing the caret, or {@code null}
- * if the HTML does not have a caret.
- */
- private void scrollTo( final Box box ) {
- if( box != null ) {
- invokeLater( () -> {
- mView.scrollTo( createPoint( box ) );
- getScrollPane().repaint();
- } );
- }
- }
-
- /**
- * Creates a {@link Point} to use as a reference for scrolling to the area
- * described by the given {@link Box}. The {@link Box} coordinates are used
- * to populate the {@link Point}'s location, with minor adjustments for
- * vertical centering.
- *
- * @param box The {@link Box} that represents a scrolling anchor reference.
- * @return A coordinate suitable for scrolling to.
- */
- private Point createPoint( final Box box ) {
- assert box != null;
-
- // Scroll back up by half the height of the scroll bar to keep the typing
- // area within the view port. Otherwise the view port will have jumped too
- // high up and the most recently typed letters won't be visible.
- int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
- int x = box.getAbsX();
-
- if( !box.getStyle().isInline() ) {
- final var margin = box.getMargin( mView.getLayoutContext() );
- y += margin.top();
- x += margin.left();
- }
-
- return new Point( x, y );
- }
-
- private String getBaseUri() {
- return mBaseUriPath;
- }
-
- private JScrollPane getScrollPane() {
- return mScrollPane;
- }
-
- public JScrollBar getVerticalScrollBar() {
- return getScrollPane().getVerticalScrollBar();
- }
-
- private int getVerticalScrollBarHeight() {
- return getVerticalScrollBar().getHeight();
- }
-
- /**
- * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
- * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
- * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
- * could return "en-Latn-CA" for Canadian English written in the Latin
- * character set.
- *
- * @return Unique identifier for language and country.
- */
- private static URL toUrl( final Locale locale ) {
- return toUrl(
- get(
- sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
- locale.getLanguage(),
- locale.getScript(),
- locale.getCountry()
- )
- );
- }
-
- private static URL toUrl( final String path ) {
- return HtmlPreview.class.getResource( path );
- }
-
- private Locale getLocale() {
- return localeProperty().toLocale();
- }
-
- private LocaleProperty localeProperty() {
- return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
- }
-
- private String getFontFamily() {
- return fontFamilyProperty().get();
- }
-
- private StringProperty fontFamilyProperty() {
- return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME );
- }
-
- private double getFontSize() {
- return fontSizeProperty().get();
- }
-
- /**
- * Returns the font size in points.
- *
- * @return The user-defined font size (in pt).
- */
- private DoubleProperty fontSizeProperty() {
- return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
- }
-
- private String getLockText( final boolean locked ) {
- return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
+import com.keenwrite.preferences.Key;
+import com.keenwrite.preferences.LocaleProperty;
+import com.keenwrite.preferences.Workspace;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.StringProperty;
+import javafx.embed.swing.SwingNode;
+import org.greenrobot.eventbus.Subscribe;
+import org.xhtmlrenderer.render.Box;
+import org.xhtmlrenderer.swing.SwingReplacedElementFactory;
+
+import javax.swing.*;
+import java.awt.*;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Locale;
+
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.Constants.*;
+import static com.keenwrite.events.Bus.register;
+import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.preferences.WorkspaceKeys.*;
+import static com.keenwrite.ui.fonts.IconFactory.getIconFont;
+import static java.awt.BorderLayout.*;
+import static java.lang.Math.max;
+import static java.lang.String.format;
+import static java.lang.Thread.sleep;
+import static javafx.scene.CacheHint.SPEED;
+import static javax.swing.SwingUtilities.invokeLater;
+import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK;
+import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT;
+
+/**
+ * Responsible for parsing an HTML document.
+ */
+public final class HtmlPreview extends SwingNode {
+
+ /**
+ * The order is important: Swing factory will replace SVG images with
+ * a blank image, which will cause the chained factory to cache the image
+ * and exit. Instead, the SVG must execute first to rasterize the content.
+ * Consequently, the chained factory must maintain insertion order.
+ */
+ private static final ChainedReplacedElementFactory FACTORY
+ = new ChainedReplacedElementFactory(
+ new SvgReplacedElementFactory(),
+ new SwingReplacedElementFactory()
+ );
+
+ /**
+ * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
+ */
+ private static final String HTML_STYLESHEET =
+ "<link rel='stylesheet' href='%s'>";
+
+ private static final String HTML_BASE =
+ "<base href='%s'>";
+
+ /**
+ * Render CSS using points (pt) not pixels (px) to reduce the chance of
+ * poor rendering. The {@link #generateHead()} method fills placeholders.
+ * When the user has not set a locale, only one stylesheet is added to
+ * the document. In order, the placeholders are as follows:
+ * <ol>
+ * <li>%s --- language</li>
+ * <li>%s --- title</li>
+ * <li>%s --- author</li>
+ * <li>%s --- keywords</li>
+ * <li>%s --- copyright</li>
+ * <li>%s --- publish date</li>
+ * <li>%s --- default stylesheet</li>
+ * <li>%s --- language-specific stylesheet</li>
+ * <li>%s --- font family</li>
+ * <li>%d --- font size (must be pixels, not points due to bug)</li>
+ * <li>%s --- base href</li>
+ * </p>
+ */
+ private static final String HTML_HEAD =
+ """
+ <!doctype html>
+ <html lang='%s'><head><title>%s</title>
+ <meta name="author" content="%s">
+ <meta name="keywords" content="%s">
+ <meta name="copyright" content="%s">
+ <meta name="date" content="%s">
+ <meta charset='utf-8'>
+ %s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body>
+ """;
+
+ private static final String HTML_TAIL = "</body></html>";
+
+ private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
+
+ /**
+ * Reusing this buffer prevents repetitious memory re-allocations.
+ */
+ private final StringBuilder mDocument = new StringBuilder( 65536 );
+
+ private HtmlPanel mView;
+ private JScrollPane mScrollPane;
+ private String mBaseUriPath = "";
+ private String mHead = "";
+
+ private volatile boolean mLocked;
+ private final JButton mScrollLockButton = new JButton();
+
+ private final Workspace mWorkspace;
+
+ /**
+ * Creates a new preview pane that can scroll to the caret position within the
+ * document.
+ *
+ * @param workspace Contains locale and font size information.
+ */
+ public HtmlPreview( final Workspace workspace ) {
+ mWorkspace = workspace;
+
+ // Attempts to prevent a flash of black un-styled content upon load.
+ setStyle( "-fx-background-color: white;" );
+
+ invokeLater( () -> {
+ mHead = generateHead();
+ mView = new HtmlPanel();
+ mScrollPane = new JScrollPane( mView );
+ final var verticalBar = mScrollPane.getVerticalScrollBar();
+ final var verticalPanel = new JPanel( new BorderLayout() );
+
+ mScrollLockButton.setFont( getIconFont( 14 ) );
+ mScrollLockButton.setText( getLockText( mLocked ) );
+ mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) );
+ mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) );
+
+ verticalPanel.add( verticalBar, CENTER );
+ verticalPanel.add( mScrollLockButton, PAGE_END );
+
+ final var wrapper = new JPanel( new BorderLayout() );
+ wrapper.add( mScrollPane, CENTER );
+ wrapper.add( verticalPanel, LINE_END );
+
+ // Enabling the cache attempts to prevent black flashes when resizing.
+ setCache( true );
+ setCacheHint( SPEED );
+ setContent( wrapper );
+
+ final var context = mView.getSharedContext();
+ final var textRenderer = context.getTextRenderer();
+ context.setReplacedElementFactory( FACTORY );
+ textRenderer.setSmoothingThreshold( 0 );
+
+ localeProperty().addListener( ( c, o, n ) -> rerender() );
+ fontFamilyProperty().addListener( ( c, o, n ) -> rerender() );
+ fontSizeProperty().addListener( ( c, o, n ) -> rerender() );
+ } );
+
+ register( this );
+ }
+
+ @Subscribe
+ public void handle( final ScrollLockEvent event ) {
+ mLocked = event.isLocked();
+ invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) );
+ }
+
+ /**
+ * Updates the internal HTML source shown in the preview pane.
+ *
+ * @param html The new HTML document to display.
+ */
+ public void render( final String html ) {
+ mView.render( decorate( html ), getBaseUri() );
+ }
+
+ /**
+ * Clears the caches then re-renders the content.
+ */
+ public void refresh() {
+ FACTORY.clearCache();
+ rerender();
+ }
+
+ /**
+ * Recomputes the HTML head then renders the document.
+ */
+ private void rerender() {
+ mHead = generateHead();
+ render( mDocument.toString() );
+ }
+
+ /**
+ * Attaches the HTML head prefix and HTML tail suffix to the given HTML
+ * string.
+ *
+ * @param html The HTML to adorn with opening and closing tags.
+ * @return A complete HTML document, ready for rendering.
+ */
+ private String decorate( final String html ) {
+ mDocument.setLength( 0 );
+ mDocument.append( html );
+
+ // Head and tail must be separate from document due to re-rendering.
+ return mHead + mDocument.toString() + HTML_TAIL;
+ }
+
+ /**
+ * Called when settings are changed that affect the HTML document preamble.
+ * This is a minor performance optimization to avoid generating the head
+ * each time that the document itself changes.
+ *
+ * @return A new doctype and HTML {@code head} element.
+ */
+ private String generateHead() {
+ final var title = getTitle();
+ final var author = getAuthor();
+ final var keywords = getKeywords();
+ final var copyright = getCopyright();
+ final var date = getDate();
+ final var locale = getLocale();
+ final var url = toUrl( locale );
+ final var base = getBaseUri();
+
+ // Point sizes are converted to pixels because of a rendering bug.
+ return format(
+ HTML_HEAD,
+ title,
+ author,
+ keywords,
+ copyright,
+ date,
+ locale.getLanguage(),
+ format( HTML_STYLESHEET, HTML_STYLE_PREVIEW ),
+ url == null ? "" : format( HTML_STYLESHEET, url ),
+ getFontFamily(),
+ toPixels( getFontSize() ),
+ base.isBlank() ? "" : format( HTML_BASE, base )
+ );
+ }
+
+ /**
+ * Clears the preview pane by rendering an empty string.
+ */
+ public void clear() {
+ render( "" );
+ }
+
+ /**
+ * Sets the base URI to the containing directory the file being edited.
+ *
+ * @param path The path to the file being edited.
+ */
+ public void setBaseUri( final Path path ) {
+ final var parent = path.getParent();
+ mBaseUriPath = parent == null ? "" : parent.toUri().toString();
+ }
+
+ /**
+ * Scrolls to the closest element matching the given identifier without
+ * waiting for the document to be ready.
+ *
+ * @param id Scroll the preview pane to this unique paragraph identifier.
+ */
+ public void scrollTo( final String id ) {
+ if( mLocked ) {
+ return;
+ }
+
+ invokeLater( () -> {
+ int iter = 0;
+ Box box = null;
+
+ while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
+ try {
+ sleep( 10 );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ scrollTo( box );
+ } );
+ }
+
+ /**
+ * Scrolls to the location specified by the {@link Box} that corresponds
+ * to a point somewhere in the preview pane. If there is no caret, then
+ * this will not change the scroll position. Changing the scroll position
+ * to the top if the {@link Box} instance is {@code null} will result in
+ * jumping around a lot and inconsistent synchronization issues.
+ *
+ * @param box The rectangular region containing the caret, or {@code null}
+ * if the HTML does not have a caret.
+ */
+ private void scrollTo( final Box box ) {
+ if( box != null ) {
+ invokeLater( () -> {
+ mView.scrollTo( createPoint( box ) );
+ getScrollPane().repaint();
+ } );
+ }
+ }
+
+ /**
+ * Creates a {@link Point} to use as a reference for scrolling to the area
+ * described by the given {@link Box}. The {@link Box} coordinates are used
+ * to populate the {@link Point}'s location, with minor adjustments for
+ * vertical centering.
+ *
+ * @param box The {@link Box} that represents a scrolling anchor reference.
+ * @return A coordinate suitable for scrolling to.
+ */
+ private Point createPoint( final Box box ) {
+ assert box != null;
+
+ // Scroll back up by half the height of the scroll bar to keep the typing
+ // area within the view port. Otherwise the view port will have jumped too
+ // high up and the most recently typed letters won't be visible.
+ int y = max( box.getAbsY() - getVerticalScrollBarHeight() / 2, 0 );
+ int x = box.getAbsX();
+
+ if( !box.getStyle().isInline() ) {
+ final var margin = box.getMargin( mView.getLayoutContext() );
+ y += margin.top();
+ x += margin.left();
+ }
+
+ return new Point( x, y );
+ }
+
+ private String getBaseUri() {
+ return mBaseUriPath;
+ }
+
+ private JScrollPane getScrollPane() {
+ return mScrollPane;
+ }
+
+ public JScrollBar getVerticalScrollBar() {
+ return getScrollPane().getVerticalScrollBar();
+ }
+
+ private int getVerticalScrollBarHeight() {
+ return getVerticalScrollBar().getHeight();
+ }
+
+ /**
+ * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen
+ * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166
+ * alpha-2 country code or UN M.49 numeric-3 area code. For example, this
+ * could return "en-Latn-CA" for Canadian English written in the Latin
+ * character set.
+ *
+ * @return Unique identifier for language and country.
+ */
+ private static URL toUrl( final Locale locale ) {
+ return toUrl(
+ get(
+ sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ),
+ locale.getLanguage(),
+ locale.getScript(),
+ locale.getCountry()
+ )
+ );
+ }
+
+ private static URL toUrl( final String path ) {
+ return HtmlPreview.class.getResource( path );
+ }
+
+ private String getTitle() {
+ return asString( KEY_DOC_TITLE );
+ }
+
+ private String getAuthor() {
+ return asString( KEY_DOC_AUTHOR );
+ }
+
+ private String getKeywords() {
+ return asString( KEY_DOC_KEYWORDS );
+ }
+
+ private String getCopyright() {
+ return asString( KEY_DOC_COPYRIGHT );
+ }
+
+ private String getDate() {
+ return asString( KEY_DOC_DATE );
+ }
+
+ private Locale getLocale() {
+ return localeProperty().toLocale();
+ }
+
+ private LocaleProperty localeProperty() {
+ return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
+ }
+
+ private String getFontFamily() {
+ return fontFamilyProperty().get();
+ }
+
+ private StringProperty fontFamilyProperty() {
+ return stringProperty( KEY_UI_FONT_PREVIEW_NAME );
+ }
+
+ private double getFontSize() {
+ return fontSizeProperty().get();
+ }
+
+ /**
+ * Returns the font size in points.
+ *
+ * @return The user-defined font size (in pt).
+ */
+ private DoubleProperty fontSizeProperty() {
+ return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE );
+ }
+
+ private String getLockText( final boolean locked ) {
+ return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() );
+ }
+
+ private StringProperty stringProperty( final Key key ) {
+ return mWorkspace.stringProperty( key );
+ }
+
+ private String asString( final Key key ) {
+ return stringProperty( key ).get();
}
}
src/main/resources/com/keenwrite/messages.properties
workspace.document.title.desc=Full document title.
workspace.document.title.title=Title
+workspace.document.keywords=Keywords
+workspace.document.keywords.desc=Comma-separated words relating to subject matter.
+workspace.document.keywords.title=Words
+workspace.document.copyright=Copyright
+workspace.document.copyright.desc=Continuous years of publication.
+workspace.document.copyright.title=Year(s)
+workspace.document.date=Publish Date
+workspace.document.date.desc=Date and time document was published.
+workspace.document.Date.title=Timestamp
workspace.r=R
Delta897 lines added, 803 lines removed, 94-line increase