Dave Jarvis' Repositories

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

Separate preference view from model, add unit test for MD images

AuthorDaveJarvis <email>
Date2020-11-03 23:43:13 GMT-0800
Commit2bb0963540b1f8dc9590d2172801bc03d371591c
Parent6cf6c79
build.gradle
dependencies {
- def v_junit = '5.4.2'
+ def v_junit = '5.5.1'
def v_flexmark = '0.62.2'
def v_jackson = '2.11.2'
}
- testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}"
testRuntime "org.junit.jupiter:junit-jupiter-engine:${v_junit}"
+ testCompile "org.junit.jupiter:junit-jupiter-api:${v_junit}"
+ testCompile "org.testfx:testfx-junit5:4.0.16-alpha"
}
src/main/java/com/keenwrite/FileEditorTabPane.java
import com.keenwrite.service.Options;
import com.keenwrite.service.Settings;
-import com.keenwrite.service.events.Notification;
-import com.keenwrite.service.events.Notifier;
-import com.keenwrite.util.Utils;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanWrapper;
-import javafx.beans.property.ReadOnlyObjectProperty;
-import javafx.beans.property.ReadOnlyObjectWrapper;
-import javafx.beans.value.ChangeListener;
-import javafx.collections.ListChangeListener;
-import javafx.collections.ObservableList;
-import javafx.event.Event;
-import javafx.scene.control.Alert;
-import javafx.scene.control.ButtonType;
-import javafx.scene.control.Tab;
-import javafx.stage.FileChooser;
-import javafx.stage.FileChooser.ExtensionFilter;
-import javafx.stage.Window;
-
-import java.io.File;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.prefs.Preferences;
-import java.util.stream.Collectors;
-
-import static com.keenwrite.Constants.GLOB_PREFIX_FILE;
-import static com.keenwrite.Constants.SETTINGS;
-import static com.keenwrite.FileType.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
-import static com.keenwrite.service.events.Notifier.YES;
-
-/**
- * Tab pane for file editors.
- */
-public final class FileEditorTabPane extends DetachableTabPane {
-
- private static final String FILTER_EXTENSION_TITLES =
- "Dialog.file.choose.filter";
-
- private static final Options sOptions = Services.load( Options.class );
- private static final Notifier sNotifier = Services.load( Notifier.class );
-
- private final ReadOnlyObjectWrapper<Path> mOpenDefinition =
- new ReadOnlyObjectWrapper<>();
- private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
- new ReadOnlyObjectWrapper<>();
- private final ReadOnlyBooleanWrapper mAnyFileEditorModified =
- new ReadOnlyBooleanWrapper();
- private final ChangeListener<Integer> mCaretPositionListener;
-
- /**
- * Constructs a new file editor tab pane.
- *
- * @param caretPositionListener Listens for changes to caret position so
- * that the status bar can update.
- */
- public FileEditorTabPane(
- final ChangeListener<Integer> caretPositionListener ) {
- final var tabs = getTabs();
-
- setFocusTraversable( false );
- setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
-
- addTabSelectionListener(
- ( tabPane, oldTab, newTab ) -> {
- if( newTab != null ) {
- mActiveFileEditor.set( (FileEditorTab) newTab );
- }
- }
- );
-
- final ChangeListener<Boolean> modifiedListener =
- ( observable, oldValue, newValue ) -> {
- for( final Tab tab : tabs ) {
- if( ((FileEditorTab) tab).isModified() ) {
- mAnyFileEditorModified.set( true );
- break;
- }
- }
- };
-
- tabs.addListener(
- (ListChangeListener<Tab>) change -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- change.getAddedSubList().forEach(
- ( tab ) -> {
- final var fet = (FileEditorTab) tab;
- fet.modifiedProperty().addListener( modifiedListener );
- } );
- }
- else if( change.wasRemoved() ) {
- change.getRemoved().forEach(
- ( tab ) -> {
- final var fet = (FileEditorTab) tab;
- fet.modifiedProperty().removeListener( modifiedListener );
- }
- );
- }
- }
-
- // Changes in the tabs may also change anyFileEditorModified property
- // (e.g. closed modified file)
- modifiedListener.changed( null, null, null );
- }
- );
-
- mCaretPositionListener = caretPositionListener;
- }
-
- /**
- * Allows observers to be notified when the current file editor tab changes.
- *
- * @param listener The listener to notify of tab change events.
- */
- public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
- // Observe the tab so that when a new tab is opened or selected,
- // a notification is kicked off.
- getSelectionModel().selectedItemProperty().addListener( listener );
- }
-
- /**
- * Returns the tab that has keyboard focus.
- *
- * @return A non-null instance.
- */
- public FileEditorTab getActiveFileEditor() {
- return mActiveFileEditor.get();
- }
-
- /**
- * Returns the property corresponding to the tab that has focus.
- *
- * @return A non-null instance.
- */
- public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
- return mActiveFileEditor.getReadOnlyProperty();
- }
-
- /**
- * Property that can answer whether the text has been modified.
- *
- * @return A non-null instance, true meaning the content has not been saved.
- */
- ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
- return mAnyFileEditorModified.getReadOnlyProperty();
- }
-
- /**
- * Creates a new editor instance from the given path.
- *
- * @param path The file to open.
- * @return A non-null instance.
- */
- private FileEditorTab createFileEditor( final Path path ) {
- assert path != null;
-
- final FileEditorTab tab = new FileEditorTab( path );
-
- tab.setOnCloseRequest( e -> {
- if( !canCloseEditor( tab ) ) {
- e.consume();
- }
- else if( isActiveFileEditor( tab ) ) {
- // Prevent prompting the user to save when there are no file editor
- // tabs open.
- mActiveFileEditor.set( null );
- }
- } );
-
- tab.addCaretPositionListener( mCaretPositionListener );
-
- return tab;
- }
-
- private boolean isActiveFileEditor( final FileEditorTab tab ) {
- return getActiveFileEditor() == tab;
- }
-
- private Path getDefaultPath() {
- final String filename = getDefaultFilename();
- return (new File( filename )).toPath();
- }
-
- private String getDefaultFilename() {
- return getSettings().getSetting( "file.default", "untitled.md" );
- }
-
- /**
- * Called to add a new {@link FileEditorTab} to the tab pane.
- */
- void newEditor() {
- final FileEditorTab tab = createFileEditor( getDefaultPath() );
-
- getTabs().add( tab );
- getSelectionModel().select( tab );
- }
-
- void openFileDialog() {
- final FileChooser dialog = createFileChooser(
- "Dialog.file.choose.open.title" );
- final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
-
- if( files != null ) {
- openFiles( files );
- }
- }
-
- /**
- * Opens the files into new editors, unless one of those files was a
- * definition file. The definition file is loaded into the definition pane,
- * but only the first one selected (multiple definition files will result in a
- * warning).
- *
- * @param files The list of non-definition files that the were requested to
- * open.
- */
- private void openFiles( final List<File> files ) {
- final List<String> extensions =
- createExtensionFilter( DEFINITION ).getExtensions();
- final var predicate = createFileTypePredicate( extensions );
-
- // The user might have opened multiple definitions files. These will
- // be discarded from the text editable files.
- final var definitions
- = files.stream().filter( predicate ).collect( Collectors.toList() );
-
- // Create a modifiable list to remove any definition files that were
- // opened.
- final var editors = new ArrayList<>( files );
-
- if( !editors.isEmpty() ) {
- saveLastDirectory( editors.get( 0 ) );
- }
-
- editors.removeAll( definitions );
-
- // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
- if( !editors.isEmpty() ) {
- openEditors( editors, 0 );
- }
-
- if( !definitions.isEmpty() ) {
- openDefinition( definitions.get( 0 ) );
- }
- }
-
- private void openEditors( final List<File> files, final int activeIndex ) {
- final int fileTally = files.size();
- final List<Tab> tabs = getTabs();
-
- // Close single unmodified "Untitled" tab.
- if( tabs.size() == 1 ) {
- final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
-
- if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
- closeEditor( fileEditor, false );
- }
- }
-
- for( int i = 0; i < fileTally; i++ ) {
- final Path path = files.get( i ).toPath();
-
- FileEditorTab fileEditorTab = findEditor( path );
-
- // Only open new files.
- if( fileEditorTab == null ) {
- fileEditorTab = createFileEditor( path );
- getTabs().add( fileEditorTab );
- }
-
- // Select the first file in the list.
- if( i == activeIndex ) {
- getSelectionModel().select( fileEditorTab );
- }
- }
- }
-
- /**
- * Returns a property that changes when a new definition file is opened.
- *
- * @return The path to a definition file that was opened.
- */
- public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
- return getOnOpenDefinitionFile().getReadOnlyProperty();
- }
-
- private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
- return mOpenDefinition;
- }
-
- /**
- * Called when the user has opened a definition file (using the file open
- * dialog box). This will replace the current set of definitions for the
- * active tab.
- *
- * @param definition The file to open.
- */
- private void openDefinition( final File definition ) {
- // TODO: Prevent reading this file twice when a new text document is opened.
- // (might be a matter of checking the value first).
- getOnOpenDefinitionFile().set( definition.toPath() );
- }
-
- /**
- * Opens the Save As dialog for the user to save the content under a new
- * path.
- *
- * @param tab The tab with contents to save.
- * @return true The contents were saved, or the tab was null.
- */
- public boolean saveEditorAs( final FileEditorTab tab ) {
- if( tab == null ) {
- return true;
- }
-
- getSelectionModel().select( tab );
-
- final var chooser = createFileChooser( "Dialog.file.choose.save.title" );
- final var file = chooser.showSaveDialog( getWindow() );
-
- if( file == null ) {
- return false;
- }
-
- saveLastDirectory( file );
- tab.setPath( file );
-
- return tab.save();
- }
-
- void saveAllEditors() {
- for( final var fileEditorTab : getAllEditors() ) {
- fileEditorTab.save();
- }
- }
-
- /**
- * Answers whether the file has had modifications.
- *
- * @param tab THe tab to check for modifications.
- * @return false The file is unmodified.
- */
- @SuppressWarnings("BooleanMethodIsAlwaysInverted")
- boolean canCloseEditor( final FileEditorTab tab ) {
- final AtomicReference<Boolean> canClose = new AtomicReference<>();
- canClose.set( true );
-
- if( tab.isModified() ) {
- final Notification message = getNotifyService().createNotification(
- Messages.get( "Alert.file.close.title" ),
- Messages.get( "Alert.file.close.text" ),
- tab.getText()
- );
-
- final Alert confirmSave = getNotifyService().createConfirmation(
- getWindow(), message );
-
- final Optional<ButtonType> buttonType = confirmSave.showAndWait();
-
- buttonType.ifPresent(
- save -> canClose.set(
- save == YES ? tab.save() : save == ButtonType.NO
- )
- );
- }
-
- return canClose.get();
- }
-
- boolean closeEditor( final FileEditorTab tab, final boolean save ) {
- if( tab == null ) {
- return true;
- }
-
- if( save ) {
- Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
- Event.fireEvent( tab, event );
-
- if( event.isConsumed() ) {
- return false;
- }
- }
-
- getTabs().remove( tab );
-
- if( tab.getOnClosed() != null ) {
- Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
- }
-
- return true;
- }
-
- boolean closeAllEditors() {
- final FileEditorTab[] allEditors = getAllEditors();
- final FileEditorTab activeEditor = getActiveFileEditor();
-
- // try to save active tab first because in case the user decides to cancel,
- // then it stays active
- if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
- return false;
- }
-
- // This should be called any time a tab changes.
- persistPreferences();
-
- // save modified tabs
- for( int i = 0; i < allEditors.length; i++ ) {
- final FileEditorTab fileEditor = allEditors[ i ];
-
- if( fileEditor == activeEditor ) {
- continue;
- }
-
- if( fileEditor.isModified() ) {
- // activate the modified tab to make its modified content visible to
- // the user
- getSelectionModel().select( i );
-
- if( !canCloseEditor( fileEditor ) ) {
- return false;
- }
- }
- }
-
- // Close all tabs.
- for( final FileEditorTab fileEditor : allEditors ) {
- if( !closeEditor( fileEditor, false ) ) {
- return false;
- }
- }
-
- return getTabs().isEmpty();
- }
-
- private FileEditorTab[] getAllEditors() {
- final ObservableList<Tab> tabs = getTabs();
- final int length = tabs.size();
- final FileEditorTab[] allEditors = new FileEditorTab[ length ];
+import com.keenwrite.service.events.Notifier;
+import com.keenwrite.util.Utils;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
+import javafx.beans.value.ChangeListener;
+import javafx.collections.ListChangeListener;
+import javafx.event.Event;
+import javafx.scene.control.Tab;
+import javafx.stage.FileChooser;
+import javafx.stage.FileChooser.ExtensionFilter;
+import javafx.stage.Window;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.prefs.Preferences;
+import java.util.stream.Collectors;
+
+import static com.keenwrite.Constants.GLOB_PREFIX_FILE;
+import static com.keenwrite.Constants.SETTINGS;
+import static com.keenwrite.FileType.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
+import static com.keenwrite.service.events.Notifier.YES;
+import static javafx.scene.control.ButtonType.NO;
+
+/**
+ * Tab pane for file editors.
+ */
+public final class FileEditorTabPane extends DetachableTabPane {
+
+ private static final String FILTER_EXTENSION_TITLES =
+ "Dialog.file.choose.filter";
+
+ private static final Options sOptions = Services.load( Options.class );
+ private static final Notifier sNotifier = Services.load( Notifier.class );
+
+ private final ReadOnlyObjectWrapper<Path> mOpenDefinition =
+ new ReadOnlyObjectWrapper<>();
+ private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
+ new ReadOnlyObjectWrapper<>();
+ private final ReadOnlyBooleanWrapper mAnyFileEditorModified =
+ new ReadOnlyBooleanWrapper();
+ private final ChangeListener<Integer> mCaretPositionListener;
+
+ /**
+ * Constructs a new file editor tab pane.
+ *
+ * @param caretPositionListener Listens for changes to caret position so
+ * that the status bar can update.
+ */
+ public FileEditorTabPane(
+ final ChangeListener<Integer> caretPositionListener ) {
+ final var tabs = getTabs();
+
+ setFocusTraversable( false );
+ setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
+
+ addTabSelectionListener(
+ ( tabPane, oldTab, newTab ) -> {
+ if( newTab != null ) {
+ mActiveFileEditor.set( (FileEditorTab) newTab );
+ }
+ }
+ );
+
+ final ChangeListener<Boolean> modifiedListener =
+ ( observable, oldValue, newValue ) -> {
+ for( final Tab tab : tabs ) {
+ if( ((FileEditorTab) tab).isModified() ) {
+ mAnyFileEditorModified.set( true );
+ break;
+ }
+ }
+ };
+
+ tabs.addListener(
+ (ListChangeListener<Tab>) change -> {
+ while( change.next() ) {
+ if( change.wasAdded() ) {
+ change.getAddedSubList().forEach(
+ ( tab ) -> {
+ final var fet = (FileEditorTab) tab;
+ fet.modifiedProperty().addListener( modifiedListener );
+ } );
+ }
+ else if( change.wasRemoved() ) {
+ change.getRemoved().forEach(
+ ( tab ) -> {
+ final var fet = (FileEditorTab) tab;
+ fet.modifiedProperty().removeListener( modifiedListener );
+ }
+ );
+ }
+ }
+
+ // Changes in the tabs may also change anyFileEditorModified property
+ // (e.g. closed modified file)
+ modifiedListener.changed( null, null, null );
+ }
+ );
+
+ mCaretPositionListener = caretPositionListener;
+ }
+
+ /**
+ * Allows observers to be notified when the current file editor tab changes.
+ *
+ * @param listener The listener to notify of tab change events.
+ */
+ public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
+ // Observe the tab so that when a new tab is opened or selected,
+ // a notification is kicked off.
+ getSelectionModel().selectedItemProperty().addListener( listener );
+ }
+
+ /**
+ * Returns the tab that has keyboard focus.
+ *
+ * @return A non-null instance.
+ */
+ public FileEditorTab getActiveFileEditor() {
+ return mActiveFileEditor.get();
+ }
+
+ /**
+ * Returns the property corresponding to the tab that has focus.
+ *
+ * @return A non-null instance.
+ */
+ public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
+ return mActiveFileEditor.getReadOnlyProperty();
+ }
+
+ /**
+ * Property that can answer whether the text has been modified.
+ *
+ * @return A non-null instance, true meaning the content has not been saved.
+ */
+ ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
+ return mAnyFileEditorModified.getReadOnlyProperty();
+ }
+
+ /**
+ * Creates a new editor instance from the given path.
+ *
+ * @param path The file to open.
+ * @return A non-null instance.
+ */
+ private FileEditorTab createFileEditor( final Path path ) {
+ assert path != null;
+
+ final FileEditorTab tab = new FileEditorTab( path );
+
+ tab.setOnCloseRequest( e -> {
+ if( !canCloseEditor( tab ) ) {
+ e.consume();
+ }
+ else if( isActiveFileEditor( tab ) ) {
+ // Prevent prompting the user to save when there are no file editor
+ // tabs open.
+ mActiveFileEditor.set( null );
+ }
+ } );
+
+ tab.addCaretPositionListener( mCaretPositionListener );
+
+ return tab;
+ }
+
+ private boolean isActiveFileEditor( final FileEditorTab tab ) {
+ return getActiveFileEditor() == tab;
+ }
+
+ private Path getDefaultPath() {
+ final String filename = getDefaultFilename();
+ return (new File( filename )).toPath();
+ }
+
+ private String getDefaultFilename() {
+ return getSettings().getSetting( "file.default", "untitled.md" );
+ }
+
+ /**
+ * Called to add a new {@link FileEditorTab} to the tab pane.
+ */
+ void newEditor() {
+ final FileEditorTab tab = createFileEditor( getDefaultPath() );
+
+ getTabs().add( tab );
+ getSelectionModel().select( tab );
+ }
+
+ void openFileDialog() {
+ final FileChooser dialog = createFileChooser(
+ "Dialog.file.choose.open.title" );
+ final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
+
+ if( files != null ) {
+ openFiles( files );
+ }
+ }
+
+ /**
+ * Opens the files into new editors, unless one of those files was a
+ * definition file. The definition file is loaded into the definition pane,
+ * but only the first one selected (multiple definition files will result in a
+ * warning).
+ *
+ * @param files The list of non-definition files that the were requested to
+ * open.
+ */
+ private void openFiles( final List<File> files ) {
+ final List<String> extensions =
+ createExtensionFilter( DEFINITION ).getExtensions();
+ final var predicate = createFileTypePredicate( extensions );
+
+ // The user might have opened multiple definitions files. These will
+ // be discarded from the text editable files.
+ final var definitions
+ = files.stream().filter( predicate ).collect( Collectors.toList() );
+
+ // Create a modifiable list to remove any definition files that were
+ // opened.
+ final var editors = new ArrayList<>( files );
+
+ if( !editors.isEmpty() ) {
+ saveLastDirectory( editors.get( 0 ) );
+ }
+
+ editors.removeAll( definitions );
+
+ // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
+ if( !editors.isEmpty() ) {
+ openEditors( editors, 0 );
+ }
+
+ if( !definitions.isEmpty() ) {
+ openDefinition( definitions.get( 0 ) );
+ }
+ }
+
+ private void openEditors( final List<File> files, final int activeIndex ) {
+ final int fileTally = files.size();
+ final List<Tab> tabs = getTabs();
+
+ // Close single unmodified "Untitled" tab.
+ if( tabs.size() == 1 ) {
+ final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
+
+ if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
+ closeEditor( fileEditor, false );
+ }
+ }
+
+ for( int i = 0; i < fileTally; i++ ) {
+ final Path path = files.get( i ).toPath();
+
+ FileEditorTab fileEditorTab = findEditor( path );
+
+ // Only open new files.
+ if( fileEditorTab == null ) {
+ fileEditorTab = createFileEditor( path );
+ getTabs().add( fileEditorTab );
+ }
+
+ // Select the first file in the list.
+ if( i == activeIndex ) {
+ getSelectionModel().select( fileEditorTab );
+ }
+ }
+ }
+
+ /**
+ * Returns a property that changes when a new definition file is opened.
+ *
+ * @return The path to a definition file that was opened.
+ */
+ public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
+ return getOnOpenDefinitionFile().getReadOnlyProperty();
+ }
+
+ private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
+ return mOpenDefinition;
+ }
+
+ /**
+ * Called when the user has opened a definition file (using the file open
+ * dialog box). This will replace the current set of definitions for the
+ * active tab.
+ *
+ * @param definition The file to open.
+ */
+ private void openDefinition( final File definition ) {
+ // TODO: Prevent reading this file twice when a new text document is opened.
+ // (might be a matter of checking the value first).
+ getOnOpenDefinitionFile().set( definition.toPath() );
+ }
+
+ /**
+ * Opens the Save As dialog for the user to save the content under a new
+ * path.
+ *
+ * @param tab The tab with contents to save.
+ * @return true The contents were saved, or the tab was null.
+ */
+ public boolean saveEditorAs( final FileEditorTab tab ) {
+ if( tab == null ) {
+ return true;
+ }
+
+ getSelectionModel().select( tab );
+
+ final var chooser = createFileChooser( "Dialog.file.choose.save.title" );
+ final var file = chooser.showSaveDialog( getWindow() );
+
+ if( file == null ) {
+ return false;
+ }
+
+ saveLastDirectory( file );
+ tab.setPath( file );
+
+ return tab.save();
+ }
+
+ void saveAllEditors() {
+ for( final var fileEditorTab : getAllEditors() ) {
+ fileEditorTab.save();
+ }
+ }
+
+ /**
+ * Answers whether the file has had modifications.
+ *
+ * @param tab THe tab to check for modifications.
+ * @return false The file is unmodified.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ boolean canCloseEditor( final FileEditorTab tab ) {
+ final var service = getNotifyService();
+ final var canClose = new AtomicReference<>(true);
+
+ if( tab.isModified() ) {
+ final var message = service.createNotification(
+ Messages.get( "Alert.file.close.title" ),
+ Messages.get( "Alert.file.close.text" ),
+ tab.getText()
+ );
+
+ final var confirmSave = service.createConfirmation(
+ getWindow(), message );
+
+ confirmSave.showAndWait().ifPresent(
+ save -> canClose.set( save == YES ? tab.save() : save == NO )
+ );
+ }
+
+ return canClose.get();
+ }
+
+ boolean closeEditor( final FileEditorTab tab, final boolean save ) {
+ if( tab == null ) {
+ return true;
+ }
+
+ if( save ) {
+ Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
+ Event.fireEvent( tab, event );
+
+ if( event.isConsumed() ) {
+ return false;
+ }
+ }
+
+ getTabs().remove( tab );
+
+ if( tab.getOnClosed() != null ) {
+ Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
+ }
+
+ return true;
+ }
+
+ boolean closeAllEditors() {
+ final FileEditorTab[] allEditors = getAllEditors();
+ final FileEditorTab activeEditor = getActiveFileEditor();
+
+ // try to save active tab first because in case the user decides to cancel,
+ // then it stays active
+ if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
+ return false;
+ }
+
+ // This should be called any time a tab changes.
+ persistPreferences();
+
+ // save modified tabs
+ for( int i = 0; i < allEditors.length; i++ ) {
+ final FileEditorTab fileEditor = allEditors[ i ];
+
+ if( fileEditor == activeEditor ) {
+ continue;
+ }
+
+ if( fileEditor.isModified() ) {
+ // activate the modified tab to make its modified content visible to
+ // the user
+ getSelectionModel().select( i );
+
+ if( !canCloseEditor( fileEditor ) ) {
+ return false;
+ }
+ }
+ }
+
+ // Close all tabs.
+ for( final FileEditorTab fileEditor : allEditors ) {
+ if( !closeEditor( fileEditor, false ) ) {
+ return false;
+ }
+ }
+
+ return getTabs().isEmpty();
+ }
+
+ private FileEditorTab[] getAllEditors() {
+ final var tabs = getTabs();
+ final int length = tabs.size();
+ final var allEditors = new FileEditorTab[ length ];
for( int i = 0; i < length; i++ ) {
src/main/java/com/keenwrite/MainWindow.java
import com.keenwrite.definition.MapInterpolator;
import com.keenwrite.definition.yaml.YamlDefinitionSource;
-import com.keenwrite.dock.control.DetachableTab;
-import com.keenwrite.dock.control.DetachableTabPane;
-import com.keenwrite.editors.DefinitionNameInjector;
-import com.keenwrite.editors.markdown.MarkdownEditorPane;
-import com.keenwrite.exceptions.MissingFileException;
-import com.keenwrite.preferences.UserPreferences;
-import com.keenwrite.preview.HtmlPreview;
-import com.keenwrite.processors.Processor;
-import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.processors.ProcessorFactory;
-import com.keenwrite.processors.markdown.MarkdownProcessor;
-import com.keenwrite.service.Options;
-import com.keenwrite.service.Snitch;
-import com.keenwrite.spelling.api.SpellCheckListener;
-import com.keenwrite.spelling.api.SpellChecker;
-import com.keenwrite.spelling.impl.PermissiveSpeller;
-import com.keenwrite.spelling.impl.SymSpellSpeller;
-import com.keenwrite.util.Action;
-import com.keenwrite.util.ActionUtils;
-import com.keenwrite.util.SeparatorAction;
-import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.util.ast.NodeVisitor;
-import com.vladsch.flexmark.util.ast.VisitHandler;
-import javafx.beans.binding.Bindings;
-import javafx.beans.binding.BooleanBinding;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableBooleanValue;
-import javafx.beans.value.ObservableValue;
-import javafx.collections.ListChangeListener.Change;
-import javafx.collections.ObservableList;
-import javafx.event.Event;
-import javafx.event.EventHandler;
-import javafx.geometry.Pos;
-import javafx.scene.Node;
-import javafx.scene.Scene;
-import javafx.scene.control.*;
-import javafx.scene.image.ImageView;
-import javafx.scene.input.KeyEvent;
-import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.VBox;
-import javafx.scene.text.Text;
-import javafx.stage.FileChooser;
-import javafx.stage.Window;
-import javafx.stage.WindowEvent;
-import javafx.util.Duration;
-import org.apache.commons.lang3.SystemUtils;
-import org.controlsfx.control.StatusBar;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.richtext.model.StyleSpansBuilder;
-import org.reactfx.value.Val;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.file.Path;
-import java.util.*;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.prefs.Preferences;
-import java.util.stream.Collectors;
-
-import static com.keenwrite.Bootstrap.APP_TITLE;
-import static com.keenwrite.Constants.*;
-import static com.keenwrite.ExportFormat.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.StatusBarNotifier.clue;
-import static com.keenwrite.processors.ProcessorFactory.processChain;
-import static com.keenwrite.util.StageState.*;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.nio.file.Files.writeString;
-import static java.util.Collections.emptyList;
-import static java.util.Collections.singleton;
-import static javafx.application.Platform.runLater;
-import static javafx.event.Event.fireEvent;
-import static javafx.scene.control.Alert.AlertType.INFORMATION;
-import static javafx.scene.input.KeyCode.ENTER;
-import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
-import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
-
-/**
- * Main window containing a tab pane in the center for file editors.
- */
-public class MainWindow implements Observer {
- /**
- * The {@code OPTIONS} variable must be declared before all other variables
- * to prevent subsequent initializations from failing due to missing user
- * preferences.
- */
- private static final Options sOptions = Services.load( Options.class );
- private static final Snitch SNITCH = Services.load( Snitch.class );
-
- private final Scene mScene;
- private final StatusBar mStatusBar;
- private final Text mLineNumberText;
- private final TextField mFindTextField;
- private final SpellChecker mSpellChecker;
-
- private final Object mMutex = new Object();
-
- /**
- * Prevents re-instantiation of processing classes.
- */
- private final Map<FileEditorTab, Processor<String>> mProcessors =
- new HashMap<>();
-
- private final Map<String, String> mResolvedMap =
- new HashMap<>( DEFAULT_MAP_SIZE );
-
- private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
- event -> rerender();
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeItem.TreeModificationEvent<Event>>
- mTreeHandler = event -> {
- exportDefinitions( getDefinitionPath() );
- interpolateResolvedMap();
- rerender();
- };
-
- /**
- * Called to inject the selected item when the user presses ENTER in the
- * definition pane.
- */
- private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
- event -> {
- if( event.getCode() == ENTER ) {
- getDefinitionNameInjector().injectSelectedItem();
- }
- };
-
- private final ChangeListener<Integer> mCaretPositionListener =
- ( observable, oldPosition, newPosition ) -> {
- processActiveTab();
- };
-
- private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
- private final DefinitionPane mDefinitionPane = createDefinitionPane();
- private final HtmlPreview mPreviewPane = createHtmlPreviewPane();
- private final DetachableTabPane mPreviewTabPane = createPreviewTabPane();
- private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
- mCaretPositionListener );
-
- /**
- * Listens on the definition pane for double-click events.
- */
- private final DefinitionNameInjector mDefinitionNameInjector
- = new DefinitionNameInjector( mDefinitionPane );
-
- public MainWindow() {
- mStatusBar = createStatusBar();
- mLineNumberText = createLineNumberText();
- mFindTextField = createFindTextField();
- mScene = createScene();
- mSpellChecker = createSpellChecker();
-
- // Add the close request listener before the window is shown.
- initLayout();
- StatusBarNotifier.setStatusBar( mStatusBar );
- }
-
- /**
- * Called after the stage is shown.
- */
- public void init() {
- initFindInput();
- initSnitch();
- initDefinitionListener();
- initTabAddedListener();
- initTabChangedListener();
- initPreferences();
- initVariableNameInjector();
- }
-
- private void initLayout() {
- final var scene = getScene();
- final var stylesheets = scene.getStylesheets();
-
- stylesheets.add( STYLESHEET_DOCK );
- stylesheets.add( STYLESHEET_SCENE );
- scene.windowProperty().addListener(
- ( unused, oldWindow, newWindow ) ->
- newWindow.setOnCloseRequest(
- e -> {
- if( !getFileEditorPane().closeAllEditors() ) {
- e.consume();
- }
- }
- )
- );
- }
-
- /**
- * Initialize the find input text field to listen on F3, ENTER, and
- * ESCAPE key presses.
- */
- private void initFindInput() {
- final TextField input = getFindTextField();
-
- input.setOnKeyPressed( ( KeyEvent event ) -> {
- switch( event.getCode() ) {
- case F3:
- case ENTER:
- editFindNext();
- break;
- case F:
- if( !event.isControlDown() ) {
- break;
- }
- case ESCAPE:
- getStatusBar().setGraphic( null );
- getActiveFileEditorTab().getEditorPane().requestFocus();
- break;
- }
- } );
-
- // Remove when the input field loses focus.
- input.focusedProperty().addListener(
- ( focused, oldFocus, newFocus ) -> {
- if( !newFocus ) {
- getStatusBar().setGraphic( null );
- }
- }
- );
- }
-
- /**
- * Watch for changes to external files. In particular, this awaits
- * modifications to any XSL files associated with XML files being edited.
- * When
- * an XSL file is modified (external to the application), the snitch's ears
- * perk up and the file is reloaded. This keeps the XSL transformation up to
- * date with what's on the file system.
- */
- private void initSnitch() {
- SNITCH.addObserver( this );
- }
-
- /**
- * Listen for {@link FileEditorTabPane} to receive open definition file
- * event.
- */
- private void initDefinitionListener() {
- getFileEditorPane().onOpenDefinitionFileProperty().addListener(
- ( final ObservableValue<? extends Path> file,
- final Path oldPath, final Path newPath ) -> {
- openDefinitions( newPath );
- rerender();
- }
- );
- }
-
- /**
- * Re-instantiates all processors then re-renders the active tab. This
- * will refresh the resolved map, force R to re-initialize, and brute-force
- * XSLT file reloads.
- */
- private void rerender() {
- runLater(
- () -> {
- resetProcessors();
- processActiveTab();
- }
- );
- }
-
- /**
- * When tabs are added, hook the various change listeners onto the new
- * tab sothat the preview pane refreshes as necessary.
- */
- private void initTabAddedListener() {
- final FileEditorTabPane editorPane = getFileEditorPane();
-
- // Make sure the text processor kicks off when new files are opened.
- final ObservableList<Tab> tabs = editorPane.getTabs();
-
- // Update the preview pane on tab changes.
- tabs.addListener(
- ( final Change<? extends Tab> change ) -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- // Multiple tabs can be added simultaneously.
- for( final Tab newTab : change.getAddedSubList() ) {
- final FileEditorTab tab = (FileEditorTab) newTab;
-
- initTextChangeListener( tab );
- initScrollEventListener( tab );
- initSpellCheckListener( tab );
-// initSyntaxListener( tab );
- }
- }
- }
- }
- );
- }
-
- private void initTextChangeListener( final FileEditorTab tab ) {
- tab.addTextChangeListener( ( __, ov, nv ) -> process( tab ) );
- }
-
- private void initScrollEventListener( final FileEditorTab tab ) {
- final var scrollPane = tab.getScrollPane();
- final var scrollBar = getPreviewPane().getVerticalScrollBar();
-
- addShowListener( scrollPane, ( __ ) -> {
- final var handler = new ScrollEventHandler( scrollPane, scrollBar );
- handler.enabledProperty().bind( tab.selectedProperty() );
- } );
- }
-
- /**
- * Listen for changes to the any particular paragraph and perform a quick
- * spell check upon it. The style classes in the editor will be changed to
- * mark any spelling mistakes in the paragraph. The user may then interact
- * with any misspelled word (i.e., any piece of text that is marked) to
- * revise the spelling.
- *
- * @param tab The tab to spellcheck.
- */
- private void initSpellCheckListener( final FileEditorTab tab ) {
- final var editor = tab.getEditorPane().getEditor();
-
- // When the editor first appears, run a full spell check. This allows
- // spell checking while typing to be restricted to the active paragraph,
- // which is usually substantially smaller than the whole document.
- addShowListener(
- editor, ( __ ) -> spellcheck( editor, editor.getText() )
- );
-
- // Use the plain text changes so that notifications of style changes
- // are suppressed. Checking against the identity ensures that only
- // new text additions or deletions trigger proofreading.
- editor.plainTextChanges()
- .filter( p -> !p.isIdentity() ).subscribe( change -> {
-
- // Only perform a spell check on the current paragraph. The
- // entire document is processed once, when opened.
- final var offset = change.getPosition();
- final var position = editor.offsetToPosition( offset, Forward );
- final var paraId = position.getMajor();
- final var paragraph = editor.getParagraph( paraId );
- final var text = paragraph.getText();
-
- // Ensure that styles aren't doubled-up.
- editor.clearStyle( paraId );
-
- spellcheck( editor, text, paraId );
- } );
- }
-
- /**
- * Listen for new tab selection events.
- */
- private void initTabChangedListener() {
- final FileEditorTabPane editorPane = getFileEditorPane();
-
- // Update the preview pane changing tabs.
- editorPane.addTabSelectionListener(
- ( __, oldTab, newTab ) -> {
- if( newTab == null ) {
- // Clear the preview pane when closing an editor. When the last
- // tab is closed, this ensures that the preview pane is empty.
- getPreviewPane().clear();
- }
- else {
- final var tab = (FileEditorTab) newTab;
- updateVariableNameInjector( tab );
- process( tab );
- }
- }
- );
- }
-
- /**
- * Reloads the preferences from the previous session.
- */
- private void initPreferences() {
- initDefinitionPane();
- getFileEditorPane().initPreferences();
- getUserPreferences().addSaveEventHandler( mRPreferencesListener );
- }
-
- private void initVariableNameInjector() {
- updateVariableNameInjector( getActiveFileEditorTab() );
- }
-
- /**
- * Calls the listener when the given node is shown for the first time. The
- * visible property is not the same as the initial showing event; visibility
- * can be triggered numerous times (such as going off screen).
- * <p>
- * This is called, for example, before the drag handler can be attached,
- * because the scrollbar for the text editor pane must be visible.
- * </p>
- *
- * @param node The node to watch for showing.
- * @param consumer The consumer to invoke when the event fires.
- */
- private void addShowListener(
- final Node node, final Consumer<Void> consumer ) {
- final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
- runLater( () -> {
- if( newShow != null && newShow ) {
- try {
- consumer.accept( null );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
- } );
-
- Val.flatMap( node.sceneProperty(), Scene::windowProperty )
- .flatMap( Window::showingProperty )
- .addListener( listener );
- }
-
- private void scrollToCaret() {
- synchronized( mMutex ) {
- getPreviewPane().scrollTo( CARET_ID );
- }
- }
-
- private void updateVariableNameInjector( final FileEditorTab tab ) {
- getDefinitionNameInjector().addListener( tab );
- }
-
- /**
- * Called to update the status bar's caret position when a new tab is added
- * or the active tab is switched.
- *
- * @param tab The active tab containing a caret position to show.
- */
- private void updateCaretStatus( final FileEditorTab tab ) {
- getLineNumberText().setText( tab.getCaretPosition().toString() );
- }
-
- /**
- * Called whenever the preview pane becomes out of sync with the file editor
- * tab. This can be called when the text changes, the caret paragraph
- * changes, or the file tab changes.
- *
- * @param tab The file editor tab that has been changed in some fashion.
- */
- private void process( final FileEditorTab tab ) {
- if( tab != null ) {
- getPreviewPane().setPath( tab.getPath() );
-
- final Processor<String> processor = getProcessors().computeIfAbsent(
- tab, p -> createProcessors( tab )
- );
-
- try {
- updateCaretStatus( tab );
- processChain( processor, tab.getEditorText() );
- scrollToCaret();
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
- }
-
- private void processActiveTab() {
- process( getActiveFileEditorTab() );
- }
-
- /**
- * Called when a definition source is opened.
- *
- * @param path Path to the definition source that was opened.
- */
- private void openDefinitions( final Path path ) {
- try {
- final var ds = createDefinitionSource( path );
- setDefinitionSource( ds );
-
- final var prefs = getUserPreferences();
- prefs.definitionPathProperty().setValue( path.toFile() );
- prefs.save();
-
- final var tooltipPath = new Tooltip( path.toString() );
- tooltipPath.setShowDelay( Duration.millis( 200 ) );
-
- final var pane = getDefinitionPane();
- pane.update( ds );
- pane.addTreeChangeHandler( mTreeHandler );
- pane.addKeyEventHandler( mDefinitionKeyHandler );
- pane.filenameProperty().setValue( path.getFileName().toString() );
- pane.setTooltip( tooltipPath );
-
- interpolateResolvedMap();
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void exportDefinitions( final Path path ) {
- try {
- final var pane = getDefinitionPane();
- final var root = pane.getTreeView().getRoot();
- final var problemChild = pane.isTreeWellFormed();
-
- if( problemChild == null ) {
- getDefinitionSource().getTreeAdapter().export( root, path );
- }
- else {
- clue( "yaml.error.tree.form", problemChild.getValue() );
- }
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void interpolateResolvedMap() {
- final var treeMap = getDefinitionPane().toMap();
- final var map = new HashMap<>( treeMap );
- MapInterpolator.interpolate( map );
-
- getResolvedMap().clear();
- getResolvedMap().putAll( map );
- }
-
- private void initDefinitionPane() {
- openDefinitions( getDefinitionPath() );
- }
-
- //---- File actions -------------------------------------------------------
-
- /**
- * Called when an {@link Observable} instance has changed. This is called
- * by both the {@link Snitch} service and the notify service. The @link
- * Snitch} service can be called for different file types, including
- * {@link DefinitionSource} instances.
- *
- * @param observable The observed instance.
- * @param value The noteworthy item.
- */
- @Override
- public void update( final Observable observable, final Object value ) {
- if( value instanceof Path && observable instanceof Snitch ) {
- updateSelectedTab();
- }
- }
-
- /**
- * Called when a file has been modified.
- */
- private void updateSelectedTab() {
- rerender();
- }
-
- /**
- * After resetting the processors, they will refresh anew to be up-to-date
- * with the files (text and definition) currently loaded into the editor.
- */
- private void resetProcessors() {
- getProcessors().clear();
- }
-
- //---- File actions -------------------------------------------------------
-
- private void fileNew() {
- getFileEditorPane().newEditor();
- }
-
- private void fileOpen() {
- getFileEditorPane().openFileDialog();
- }
-
- private void fileClose() {
- getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
- }
-
- /**
- * TODO: Upon closing, first remove the tab change listeners. (There's no
- * need to re-render each tab when all are being closed.)
- */
- private void fileCloseAll() {
- getFileEditorPane().closeAllEditors();
- }
-
- private void fileSaveAs() {
- final FileEditorTab editor = getActiveFileEditorTab();
- getFileEditorPane().saveEditorAs( editor );
- getProcessors().remove( editor );
-
- try {
- process( editor );
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- private void fileSaveAll() {
- getFileEditorPane().saveAllEditors();
- }
-
- /**
- * Exports the contents of the current tab according to the given
- * {@link ExportFormat}.
- *
- * @param format Configures the {@link MarkdownProcessor} when exporting.
- */
- private void fileExport( final ExportFormat format ) {
- final var tab = getActiveFileEditorTab();
- final var context = createProcessorContext( tab, format );
- final var chain = ProcessorFactory.createProcessors( context );
- final var doc = tab.getEditorText();
- final var export = processChain( chain, doc );
-
- final var filename = format.toExportFilename( tab.getPath().toFile() );
- final var dir = getPreferences().get( "lastDirectory", null );
- final var lastDir = new File( dir == null ? "." : dir );
-
- final FileChooser chooser = new FileChooser();
- chooser.setTitle( get( "Dialog.file.choose.export.title" ) );
- chooser.setInitialFileName( filename.getName() );
- chooser.setInitialDirectory( lastDir );
-
- final File file = chooser.showSaveDialog( getWindow() );
-
- if( file != null ) {
- try {
- writeString( file.toPath(), export, UTF_8 );
- final var m = get( "Main.status.export.success", file.toString() );
- clue( m );
- } catch( final IOException e ) {
- clue( e );
- }
- }
- }
-
- private void fileExit() {
- final Window window = getWindow();
- fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
- }
-
- //---- Edit actions -------------------------------------------------------
-
- /**
- * Used to find text in the active file editor window.
- */
- private void editFind() {
- final TextField input = getFindTextField();
- getStatusBar().setGraphic( input );
- input.requestFocus();
- }
-
- public void editFindNext() {
- getActiveFileEditorTab().searchNext( getFindTextField().getText() );
- }
-
- public void editPreferences() {
- getUserPreferences().show();
- }
-
- //---- Insert actions -----------------------------------------------------
-
- /**
- * Delegates to the active editor to handle wrapping the current text
- * selection with leading and trailing strings.
- *
- * @param leading The string to put before the selection.
- * @param trailing The string to put after the selection.
- */
- private void insertMarkdown(
- final String leading, final String trailing ) {
- getActiveEditorPane().surroundSelection( leading, trailing );
- }
-
- private void insertMarkdown(
- final String leading, final String trailing, final String hint ) {
- getActiveEditorPane().surroundSelection( leading, trailing, hint );
- }
-
- //---- Help actions -------------------------------------------------------
-
- private void helpAbout() {
- final Alert alert = new Alert( INFORMATION );
- alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
- alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
- alert.setContentText( get( "Dialog.about.content" ) );
- alert.setGraphic( new ImageView( ICON_DIALOG ) );
- alert.initOwner( getWindow() );
-
- alert.showAndWait();
- }
-
- //---- Member creators ----------------------------------------------------
-
- private SpellChecker createSpellChecker() {
- try {
- final Collection<String> lexicon = readLexicon( "en.txt" );
- return SymSpellSpeller.forLexicon( lexicon );
- } catch( final Exception ex ) {
- clue( ex );
- return new PermissiveSpeller();
- }
- }
-
- /**
- * Creates processors suited to parsing and rendering different file types.
- *
- * @param tab The tab that is subjected to processing.
- * @return A processor suited to the file type specified by the tab's path.
- */
- private Processor<String> createProcessors( final FileEditorTab tab ) {
- final var context = createProcessorContext( tab );
- return ProcessorFactory.createProcessors( context );
- }
-
- private ProcessorContext createProcessorContext(
- final FileEditorTab tab, final ExportFormat format ) {
- final var preview = getPreviewPane();
- final var map = getResolvedMap();
- return new ProcessorContext( preview, map, tab, format );
- }
-
- private ProcessorContext createProcessorContext( final FileEditorTab tab ) {
- return createProcessorContext( tab, NONE );
- }
-
- private DefinitionPane createDefinitionPane() {
- return new DefinitionPane();
- }
-
- private HtmlPreview createHtmlPreviewPane() {
- return new HtmlPreview();
- }
-
- private DetachableTabPane createPreviewTabPane() {
- final var previewTabPane = new DetachableTabPane();
- final var previewTab = new DetachableTab( "HTML", getPreviewPane() );
- previewTabPane.getTabs().add( previewTab );
-
- return previewTabPane;
- }
-
- private DefinitionSource createDefaultDefinitionSource() {
- return new YamlDefinitionSource( getDefinitionPath() );
- }
-
- private DefinitionSource createDefinitionSource( final Path path ) {
- try {
- return createDefinitionFactory().createDefinitionSource( path );
- } catch( final Exception ex ) {
- clue( ex );
- return createDefaultDefinitionSource();
- }
- }
-
- private TextField createFindTextField() {
- return new TextField();
- }
-
- private DefinitionFactory createDefinitionFactory() {
- return new DefinitionFactory();
- }
-
- private StatusBar createStatusBar() {
- return new StatusBar();
- }
-
- private Scene createScene() {
- final var splitPane = new SplitPane(
- getDefinitionPane(),
- getFileEditorPane(),
- getPreviewTabPane() );
-
- splitPane.setDividerPositions(
- getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
- getFloat( K_PANE_SPLIT_EDITOR, .60f ),
- getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
-
- getDefinitionPane().prefHeightProperty()
- .bind( splitPane.heightProperty() );
-
- final BorderPane borderPane = new BorderPane();
- borderPane.setPrefSize( 1280, 800 );
- borderPane.setTop( createMenuBar() );
- borderPane.setBottom( getStatusBar() );
- borderPane.setCenter( splitPane );
-
- final VBox statusBar = new VBox();
- statusBar.setAlignment( Pos.BASELINE_CENTER );
- statusBar.getChildren().add( getLineNumberText() );
- getStatusBar().getRightItems().add( statusBar );
-
- // Force preview pane refresh on Windows.
- if( SystemUtils.IS_OS_WINDOWS ) {
- splitPane.getDividers().get( 1 ).positionProperty().addListener(
- ( l, oValue, nValue ) -> runLater(
- () -> getPreviewPane().repaintScrollPane()
- )
- );
- }
-
- return new Scene( borderPane );
- }
-
- private Text createLineNumberText() {
- return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
- }
-
- private Node createMenuBar() {
- final BooleanBinding activeFileEditorIsNull =
- getFileEditorPane().activeFileEditorProperty().isNull();
-
- // File actions
- final Action fileNewAction = Action
- .builder()
- .setText( "Main.menu.file.new" )
- .setAccelerator( "Shortcut+N" )
- .setIcon( FILE_ALT )
- .setAction( e -> fileNew() )
- .build();
- final Action fileOpenAction = Action
- .builder()
- .setText( "Main.menu.file.open" )
- .setAccelerator( "Shortcut+O" )
- .setIcon( FOLDER_OPEN_ALT )
- .setAction( e -> fileOpen() )
- .build();
- final Action fileCloseAction = Action
- .builder()
- .setText( "Main.menu.file.close" )
- .setAccelerator( "Shortcut+W" )
- .setAction( e -> fileClose() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action fileCloseAllAction = Action
- .builder()
- .setText( "Main.menu.file.close_all" )
- .setAction( e -> fileCloseAll() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action fileSaveAction = Action
- .builder()
- .setText( "Main.menu.file.save" )
- .setAccelerator( "Shortcut+S" )
- .setIcon( FLOPPY_ALT )
- .setAction( e -> getActiveFileEditorTab().save() )
- .setDisabled( createActiveBooleanProperty(
- FileEditorTab::modifiedProperty ).not() )
- .build();
- final Action fileSaveAsAction = Action
- .builder()
- .setText( "Main.menu.file.save_as" )
- .setAction( e -> fileSaveAs() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action fileSaveAllAction = Action
- .builder()
- .setText( "Main.menu.file.save_all" )
- .setAccelerator( "Shortcut+Shift+S" )
- .setAction( e -> fileSaveAll() )
- .setDisabled( Bindings.not(
- getFileEditorPane().anyFileEditorModifiedProperty() ) )
- .build();
- final Action fileExportAction = Action
- .builder()
- .setText( "Main.menu.file.export" )
- .build();
- final Action fileExportHtmlSvgAction = Action
- .builder()
- .setText( "Main.menu.file.export.html_svg" )
- .setAction( e -> fileExport( HTML_TEX_SVG ) )
- .build();
- final Action fileExportHtmlTexAction = Action
- .builder()
- .setText( "Main.menu.file.export.html_tex" )
- .setAction( e -> fileExport( HTML_TEX_DELIMITED ) )
- .build();
- final Action fileExportMarkdownAction = Action
- .builder()
- .setText( "Main.menu.file.export.markdown" )
- .setAction( e -> fileExport( MARKDOWN_PLAIN ) )
- .build();
- fileExportAction.addSubActions(
- fileExportHtmlSvgAction,
- fileExportHtmlTexAction,
- fileExportMarkdownAction );
-
- final Action fileExitAction = Action
- .builder()
- .setText( "Main.menu.file.exit" )
- .setAction( e -> fileExit() )
- .build();
-
- // Edit actions
- final Action editUndoAction = Action
- .builder()
- .setText( "Main.menu.edit.undo" )
- .setAccelerator( "Shortcut+Z" )
- .setIcon( UNDO )
- .setAction( e -> getActiveEditorPane().undo() )
- .setDisabled( createActiveBooleanProperty(
- FileEditorTab::canUndoProperty ).not() )
- .build();
- final Action editRedoAction = Action
- .builder()
- .setText( "Main.menu.edit.redo" )
- .setAccelerator( "Shortcut+Y" )
- .setIcon( REPEAT )
- .setAction( e -> getActiveEditorPane().redo() )
- .setDisabled( createActiveBooleanProperty(
- FileEditorTab::canRedoProperty ).not() )
- .build();
-
- final Action editCutAction = Action
- .builder()
- .setText( "Main.menu.edit.cut" )
- .setAccelerator( "Shortcut+X" )
- .setIcon( CUT )
- .setAction( e -> getActiveEditorPane().cut() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action editCopyAction = Action
- .builder()
- .setText( "Main.menu.edit.copy" )
- .setAccelerator( "Shortcut+C" )
- .setIcon( COPY )
- .setAction( e -> getActiveEditorPane().copy() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action editPasteAction = Action
- .builder()
- .setText( "Main.menu.edit.paste" )
- .setAccelerator( "Shortcut+V" )
- .setIcon( PASTE )
- .setAction( e -> getActiveEditorPane().paste() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action editSelectAllAction = Action
- .builder()
- .setText( "Main.menu.edit.selectAll" )
- .setAccelerator( "Shortcut+A" )
- .setAction( e -> getActiveEditorPane().selectAll() )
- .setDisabled( activeFileEditorIsNull )
- .build();
-
- final Action editFindAction = Action
- .builder()
- .setText( "Main.menu.edit.find" )
- .setAccelerator( "Ctrl+F" )
- .setIcon( SEARCH )
- .setAction( e -> editFind() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action editFindNextAction = Action
- .builder()
- .setText( "Main.menu.edit.find.next" )
- .setAccelerator( "F3" )
- .setAction( e -> editFindNext() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action editPreferencesAction = Action
- .builder()
- .setText( "Main.menu.edit.preferences" )
- .setAccelerator( "Ctrl+Alt+S" )
- .setAction( e -> editPreferences() )
- .build();
-
- // Format actions
- final Action formatBoldAction = Action
- .builder()
- .setText( "Main.menu.format.bold" )
- .setAccelerator( "Shortcut+B" )
- .setIcon( BOLD )
- .setAction( e -> insertMarkdown( "**", "**" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action formatItalicAction = Action
- .builder()
- .setText( "Main.menu.format.italic" )
- .setAccelerator( "Shortcut+I" )
- .setIcon( ITALIC )
- .setAction( e -> insertMarkdown( "*", "*" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action formatSuperscriptAction = Action
- .builder()
- .setText( "Main.menu.format.superscript" )
- .setAccelerator( "Shortcut+[" )
- .setIcon( SUPERSCRIPT )
- .setAction( e -> insertMarkdown( "^", "^" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action formatSubscriptAction = Action
- .builder()
- .setText( "Main.menu.format.subscript" )
- .setAccelerator( "Shortcut+]" )
- .setIcon( SUBSCRIPT )
- .setAction( e -> insertMarkdown( "~", "~" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action formatStrikethroughAction = Action
- .builder()
- .setText( "Main.menu.format.strikethrough" )
- .setAccelerator( "Shortcut+T" )
- .setIcon( STRIKETHROUGH )
- .setAction( e -> insertMarkdown( "~~", "~~" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
-
- // Insert actions
- final Action insertBlockquoteAction = Action
- .builder()
- .setText( "Main.menu.insert.blockquote" )
- .setAccelerator( "Ctrl+Q" )
- .setIcon( QUOTE_LEFT )
- .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action insertCodeAction = Action
- .builder()
- .setText( "Main.menu.insert.code" )
- .setAccelerator( "Shortcut+K" )
- .setIcon( CODE )
- .setAction( e -> insertMarkdown( "`", "`" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action insertFencedCodeBlockAction = Action
- .builder()
- .setText( "Main.menu.insert.fenced_code_block" )
- .setAccelerator( "Shortcut+Shift+K" )
- .setIcon( FILE_CODE_ALT )
- .setAction( e -> insertMarkdown(
- "\n\n```\n",
- "\n```\n\n",
- get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action insertLinkAction = Action
- .builder()
- .setText( "Main.menu.insert.link" )
- .setAccelerator( "Shortcut+L" )
- .setIcon( LINK )
- .setAction( e -> getActiveEditorPane().insertLink() )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action insertImageAction = Action
- .builder()
- .setText( "Main.menu.insert.image" )
- .setAccelerator( "Shortcut+G" )
- .setIcon( PICTURE_ALT )
- .setAction( e -> getActiveEditorPane().insertImage() )
- .setDisabled( activeFileEditorIsNull )
- .build();
-
- // Number of heading actions (H1 ... H3)
- final int HEADINGS = 3;
- final Action[] headings = new Action[ HEADINGS ];
-
- for( int i = 1; i <= HEADINGS; i++ ) {
- final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
- final String markup = String.format( "%n%n%s ", hashes );
- final String text = "Main.menu.insert.heading." + i;
- final String accelerator = "Shortcut+" + i;
- final String prompt = text + ".prompt";
-
- headings[ i - 1 ] = Action
- .builder()
- .setText( text )
- .setAccelerator( accelerator )
- .setIcon( HEADER )
- .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- }
-
- final Action insertUnorderedListAction = Action
- .builder()
- .setText( "Main.menu.insert.unordered_list" )
- .setAccelerator( "Shortcut+U" )
- .setIcon( LIST_UL )
- .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action insertOrderedListAction = Action
- .builder()
- .setText( "Main.menu.insert.ordered_list" )
- .setAccelerator( "Shortcut+Shift+O" )
- .setIcon( LIST_OL )
- .setAction( e -> insertMarkdown(
- "\n\n1. ", "" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
- final Action insertHorizontalRuleAction = Action
- .builder()
- .setText( "Main.menu.insert.horizontal_rule" )
- .setAccelerator( "Shortcut+H" )
- .setAction( e -> insertMarkdown(
- "\n\n---\n\n", "" ) )
- .setDisabled( activeFileEditorIsNull )
- .build();
-
- // Definition actions
- final Action definitionCreateAction = Action
- .builder()
- .setText( "Main.menu.definition.create" )
- .setIcon( TREE )
- .setAction( e -> getDefinitionPane().addItem() )
- .build();
- final Action definitionInsertAction = Action
- .builder()
- .setText( "Main.menu.definition.insert" )
- .setAccelerator( "Ctrl+Space" )
- .setIcon( STAR )
- .setAction( e -> definitionInsert() )
- .build();
-
- // Help actions
- final Action helpAboutAction = Action
- .builder()
- .setText( "Main.menu.help.about" )
- .setAction( e -> helpAbout() )
- .build();
-
- final Action SEPARATOR_ACTION = new SeparatorAction();
-
- //---- MenuBar ----
-
- // File Menu
- final var fileMenu = ActionUtils.createMenu(
- get( "Main.menu.file" ),
- fileNewAction,
- fileOpenAction,
- SEPARATOR_ACTION,
- fileCloseAction,
- fileCloseAllAction,
- SEPARATOR_ACTION,
- fileSaveAction,
- fileSaveAsAction,
- fileSaveAllAction,
- SEPARATOR_ACTION,
- fileExportAction,
- SEPARATOR_ACTION,
- fileExitAction );
-
- // Edit Menu
- final var editMenu = ActionUtils.createMenu(
- get( "Main.menu.edit" ),
- SEPARATOR_ACTION,
- editUndoAction,
- editRedoAction,
- SEPARATOR_ACTION,
- editCutAction,
- editCopyAction,
- editPasteAction,
- editSelectAllAction,
- SEPARATOR_ACTION,
- editFindAction,
- editFindNextAction,
- SEPARATOR_ACTION,
- editPreferencesAction );
-
- // Format Menu
- final var formatMenu = ActionUtils.createMenu(
- get( "Main.menu.format" ),
- formatBoldAction,
- formatItalicAction,
- formatSuperscriptAction,
- formatSubscriptAction,
- formatStrikethroughAction
- );
-
- // Insert Menu
- final var insertMenu = ActionUtils.createMenu(
- get( "Main.menu.insert" ),
- insertBlockquoteAction,
- insertCodeAction,
- insertFencedCodeBlockAction,
- SEPARATOR_ACTION,
- insertLinkAction,
- insertImageAction,
- SEPARATOR_ACTION,
- headings[ 0 ],
- headings[ 1 ],
- headings[ 2 ],
- SEPARATOR_ACTION,
- insertUnorderedListAction,
- insertOrderedListAction,
- insertHorizontalRuleAction
- );
-
- // Definition Menu
- final var definitionMenu = ActionUtils.createMenu(
- get( "Main.menu.definition" ),
- definitionCreateAction,
- definitionInsertAction );
-
- // Help Menu
- final var helpMenu = ActionUtils.createMenu(
- get( "Main.menu.help" ),
- helpAboutAction );
-
- //---- MenuBar ----
- final var menuBar = new MenuBar(
- fileMenu,
- editMenu,
- formatMenu,
- insertMenu,
- definitionMenu,
- helpMenu );
-
- //---- ToolBar ----
- final var toolBar = ActionUtils.createToolBar(
- fileNewAction,
- fileOpenAction,
- fileSaveAction,
- SEPARATOR_ACTION,
- editUndoAction,
- editRedoAction,
- editCutAction,
- editCopyAction,
- editPasteAction,
- SEPARATOR_ACTION,
- formatBoldAction,
- formatItalicAction,
- formatSuperscriptAction,
- formatSubscriptAction,
- insertBlockquoteAction,
- insertCodeAction,
- insertFencedCodeBlockAction,
- SEPARATOR_ACTION,
- insertLinkAction,
- insertImageAction,
- SEPARATOR_ACTION,
- headings[ 0 ],
- SEPARATOR_ACTION,
- insertUnorderedListAction,
- insertOrderedListAction );
-
- return new VBox( menuBar, toolBar );
- }
-
- /**
- * Performs the autoinsert function on the active file editor.
- */
- private void definitionInsert() {
- getDefinitionNameInjector().autoinsert();
- }
-
- /**
- * Creates a boolean property that is bound to another boolean value of the
- * active editor.
- */
- private BooleanProperty createActiveBooleanProperty(
- final Function<FileEditorTab, ObservableBooleanValue> func ) {
-
- final BooleanProperty b = new SimpleBooleanProperty();
- final FileEditorTab tab = getActiveFileEditorTab();
-
- if( tab != null ) {
- b.bind( func.apply( tab ) );
- }
-
- getFileEditorPane().activeFileEditorProperty().addListener(
- ( __, oldFileEditor, newFileEditor ) -> {
- b.unbind();
-
- if( newFileEditor == null ) {
- b.set( false );
- }
- else {
- b.bind( func.apply( newFileEditor ) );
- }
- }
- );
-
- return b;
- }
-
- //---- Convenience accessors ----------------------------------------------
-
- private Preferences getPreferences() {
- return sOptions.getState();
- }
-
- private float getFloat( final String key, final float defaultValue ) {
- return getPreferences().getFloat( key, defaultValue );
- }
-
- public Window getWindow() {
- return getScene().getWindow();
- }
-
- private MarkdownEditorPane getActiveEditorPane() {
- return getActiveFileEditorTab().getEditorPane();
- }
-
- private FileEditorTab getActiveFileEditorTab() {
- return getFileEditorPane().getActiveFileEditor();
- }
-
- //---- Member accessors ---------------------------------------------------
-
- protected Scene getScene() {
- return mScene;
- }
-
- private SpellChecker getSpellChecker() {
- return mSpellChecker;
- }
-
- private Map<FileEditorTab, Processor<String>> getProcessors() {
- return mProcessors;
- }
-
- private FileEditorTabPane getFileEditorPane() {
- return mFileEditorPane;
- }
-
- private DetachableTabPane getPreviewTabPane() {
- return mPreviewTabPane;
- }
-
- private HtmlPreview getPreviewPane() {
- return mPreviewPane;
- }
-
- private void setDefinitionSource(
- final DefinitionSource definitionSource ) {
- assert definitionSource != null;
- mDefinitionSource = definitionSource;
- }
-
- private DefinitionSource getDefinitionSource() {
- return mDefinitionSource;
- }
-
- private DefinitionPane getDefinitionPane() {
- return mDefinitionPane;
- }
-
- private Text getLineNumberText() {
- return mLineNumberText;
- }
-
- private StatusBar getStatusBar() {
- return mStatusBar;
- }
-
- private TextField getFindTextField() {
- return mFindTextField;
- }
-
- private DefinitionNameInjector getDefinitionNameInjector() {
- return mDefinitionNameInjector;
- }
-
- /**
- * Returns the variable map of interpolated definitions.
- *
- * @return A map to help dereference variables.
- */
- private Map<String, String> getResolvedMap() {
- return mResolvedMap;
- }
-
- //---- Persistence accessors ----------------------------------------------
-
- private UserPreferences getUserPreferences() {
- return UserPreferences.getInstance();
+import com.keenwrite.editors.DefinitionNameInjector;
+import com.keenwrite.editors.markdown.MarkdownEditorPane;
+import com.keenwrite.exceptions.MissingFileException;
+import com.keenwrite.preferences.UserPreferences;
+import com.keenwrite.preferences.UserPreferencesView;
+import com.keenwrite.preview.HtmlPreview;
+import com.keenwrite.preview.OutputTabPane;
+import com.keenwrite.processors.Processor;
+import com.keenwrite.processors.ProcessorContext;
+import com.keenwrite.processors.ProcessorFactory;
+import com.keenwrite.processors.markdown.MarkdownProcessor;
+import com.keenwrite.service.Options;
+import com.keenwrite.service.Snitch;
+import com.keenwrite.spelling.api.SpellCheckListener;
+import com.keenwrite.spelling.api.SpellChecker;
+import com.keenwrite.spelling.impl.PermissiveSpeller;
+import com.keenwrite.spelling.impl.SymSpellSpeller;
+import com.keenwrite.util.Action;
+import com.keenwrite.util.ActionUtils;
+import com.keenwrite.util.SeparatorAction;
+import com.vladsch.flexmark.parser.Parser;
+import com.vladsch.flexmark.util.ast.NodeVisitor;
+import com.vladsch.flexmark.util.ast.VisitHandler;
+import javafx.beans.binding.Bindings;
+import javafx.beans.binding.BooleanBinding;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableBooleanValue;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ListChangeListener.Change;
+import javafx.collections.ObservableList;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.*;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.VBox;
+import javafx.scene.text.Text;
+import javafx.stage.FileChooser;
+import javafx.stage.Window;
+import javafx.stage.WindowEvent;
+import javafx.util.Duration;
+import org.apache.commons.lang3.SystemUtils;
+import org.controlsfx.control.StatusBar;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.fxmisc.richtext.model.StyleSpansBuilder;
+import org.reactfx.value.Val;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.prefs.Preferences;
+import java.util.stream.Collectors;
+
+import static com.keenwrite.Bootstrap.APP_TITLE;
+import static com.keenwrite.Constants.*;
+import static com.keenwrite.ExportFormat.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.StatusBarNotifier.clue;
+import static com.keenwrite.processors.ProcessorFactory.processChain;
+import static com.keenwrite.util.StageState.*;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.writeString;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singleton;
+import static javafx.application.Platform.runLater;
+import static javafx.event.Event.fireEvent;
+import static javafx.scene.control.Alert.AlertType.INFORMATION;
+import static javafx.scene.input.KeyCode.ENTER;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
+
+/**
+ * Main window containing a tab pane in the center for file editors.
+ */
+public class MainWindow implements Observer {
+ /**
+ * The {@code OPTIONS} variable must be declared before all other variables
+ * to prevent subsequent initializations from failing due to missing user
+ * preferences.
+ */
+ private static final Options sOptions = Services.load( Options.class );
+ private static final Snitch SNITCH = Services.load( Snitch.class );
+
+ private final Scene mScene;
+ private final Text mLineNumberText;
+ private final TextField mFindTextField;
+ private final SpellChecker mSpellChecker;
+
+ private final Object mMutex = new Object();
+
+ /**
+ * Prevents re-instantiation of processing classes.
+ */
+ private final Map<FileEditorTab, Processor<String>> mProcessors =
+ new HashMap<>();
+
+ private final Map<String, String> mResolvedMap =
+ new HashMap<>( DEFAULT_MAP_SIZE );
+
+ private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
+ event -> rerender();
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeItem.TreeModificationEvent<Event>>
+ mTreeHandler = event -> {
+ exportDefinitions( getDefinitionPath() );
+ interpolateResolvedMap();
+ rerender();
+ };
+
+ /**
+ * Called to inject the selected item when the user presses ENTER in the
+ * definition pane.
+ */
+ private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
+ event -> {
+ if( event.getCode() == ENTER ) {
+ getDefinitionNameInjector().injectSelectedItem();
+ }
+ };
+
+ private final ChangeListener<Integer> mCaretPositionListener =
+ ( observable, oldPosition, newPosition ) -> {
+ processActiveTab();
+ };
+
+ private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
+ private final DefinitionPane mDefinitionPane = createDefinitionPane();
+ private final OutputTabPane mOutputPane = createOutputTabPane();
+ private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
+ mCaretPositionListener );
+
+ /**
+ * Listens on the definition pane for double-click events.
+ */
+ private final DefinitionNameInjector mDefinitionNameInjector
+ = new DefinitionNameInjector( mDefinitionPane );
+
+ public MainWindow() {
+ mLineNumberText = createLineNumberText();
+ mFindTextField = createFindTextField();
+ mScene = createScene();
+ mSpellChecker = createSpellChecker();
+
+ // Add the close request listener before the window is shown.
+ initLayout();
+ }
+
+ /**
+ * Called after the stage is shown.
+ */
+ public void init() {
+ initFindInput();
+ initSnitch();
+ initDefinitionListener();
+ initTabAddedListener();
+ initTabChangedListener();
+ initPreferences();
+ initVariableNameInjector();
+ }
+
+ private void initLayout() {
+ final var scene = getScene();
+ final var stylesheets = scene.getStylesheets();
+
+ stylesheets.add( STYLESHEET_DOCK );
+ stylesheets.add( STYLESHEET_SCENE );
+ scene.windowProperty().addListener(
+ ( unused, oldWindow, newWindow ) ->
+ newWindow.setOnCloseRequest(
+ e -> {
+ if( !getFileEditorPane().closeAllEditors() ) {
+ e.consume();
+ }
+ }
+ )
+ );
+ }
+
+ /**
+ * Initialize the find input text field to listen on F3, ENTER, and
+ * ESCAPE key presses.
+ */
+ private void initFindInput() {
+ final TextField input = getFindTextField();
+
+ input.setOnKeyPressed( ( KeyEvent event ) -> {
+ switch( event.getCode() ) {
+ case F3:
+ case ENTER:
+ editFindNext();
+ break;
+ case F:
+ if( !event.isControlDown() ) {
+ break;
+ }
+ case ESCAPE:
+ getStatusBar().setGraphic( null );
+ getActiveFileEditorTab().getEditorPane().requestFocus();
+ break;
+ }
+ } );
+
+ // Remove when the input field loses focus.
+ input.focusedProperty().addListener(
+ ( focused, oldFocus, newFocus ) -> {
+ if( !newFocus ) {
+ getStatusBar().setGraphic( null );
+ }
+ }
+ );
+ }
+
+ /**
+ * Watch for changes to external files. In particular, this awaits
+ * modifications to any XSL files associated with XML files being edited.
+ * When
+ * an XSL file is modified (external to the application), the snitch's ears
+ * perk up and the file is reloaded. This keeps the XSL transformation up to
+ * date with what's on the file system.
+ */
+ private void initSnitch() {
+ SNITCH.addObserver( this );
+ }
+
+ /**
+ * Listen for {@link FileEditorTabPane} to receive open definition file
+ * event.
+ */
+ private void initDefinitionListener() {
+ getFileEditorPane().onOpenDefinitionFileProperty().addListener(
+ ( final ObservableValue<? extends Path> file,
+ final Path oldPath, final Path newPath ) -> {
+ openDefinitions( newPath );
+ rerender();
+ }
+ );
+ }
+
+ /**
+ * Re-instantiates all processors then re-renders the active tab. This
+ * will refresh the resolved map, force R to re-initialize, and brute-force
+ * XSLT file reloads.
+ */
+ private void rerender() {
+ runLater(
+ () -> {
+ resetProcessors();
+ processActiveTab();
+ }
+ );
+ }
+
+ /**
+ * When tabs are added, hook the various change listeners onto the new
+ * tab sothat the preview pane refreshes as necessary.
+ */
+ private void initTabAddedListener() {
+ final FileEditorTabPane editorPane = getFileEditorPane();
+
+ // Make sure the text processor kicks off when new files are opened.
+ final ObservableList<Tab> tabs = editorPane.getTabs();
+
+ // Update the preview pane on tab changes.
+ tabs.addListener(
+ ( final Change<? extends Tab> change ) -> {
+ while( change.next() ) {
+ if( change.wasAdded() ) {
+ // Multiple tabs can be added simultaneously.
+ for( final Tab newTab : change.getAddedSubList() ) {
+ final FileEditorTab tab = (FileEditorTab) newTab;
+
+ initTextChangeListener( tab );
+ initScrollEventListener( tab );
+ initSpellCheckListener( tab );
+// initSyntaxListener( tab );
+ }
+ }
+ }
+ }
+ );
+ }
+
+ private void initTextChangeListener( final FileEditorTab tab ) {
+ tab.addTextChangeListener( ( __, ov, nv ) -> process( tab ) );
+ }
+
+ private void initScrollEventListener( final FileEditorTab tab ) {
+ final var scrollPane = tab.getScrollPane();
+ final var scrollBar = getHtmlPreview().getVerticalScrollBar();
+
+ addShowListener( scrollPane, ( __ ) -> {
+ final var handler = new ScrollEventHandler( scrollPane, scrollBar );
+ handler.enabledProperty().bind( tab.selectedProperty() );
+ } );
+ }
+
+ /**
+ * Listen for changes to the any particular paragraph and perform a quick
+ * spell check upon it. The style classes in the editor will be changed to
+ * mark any spelling mistakes in the paragraph. The user may then interact
+ * with any misspelled word (i.e., any piece of text that is marked) to
+ * revise the spelling.
+ *
+ * @param tab The tab to spellcheck.
+ */
+ private void initSpellCheckListener( final FileEditorTab tab ) {
+ final var editor = tab.getEditorPane().getEditor();
+
+ // When the editor first appears, run a full spell check. This allows
+ // spell checking while typing to be restricted to the active paragraph,
+ // which is usually substantially smaller than the whole document.
+ addShowListener(
+ editor, ( __ ) -> spellcheck( editor, editor.getText() )
+ );
+
+ // Use the plain text changes so that notifications of style changes
+ // are suppressed. Checking against the identity ensures that only
+ // new text additions or deletions trigger proofreading.
+ editor.plainTextChanges()
+ .filter( p -> !p.isIdentity() ).subscribe( change -> {
+
+ // Only perform a spell check on the current paragraph. The
+ // entire document is processed once, when opened.
+ final var offset = change.getPosition();
+ final var position = editor.offsetToPosition( offset, Forward );
+ final var paraId = position.getMajor();
+ final var paragraph = editor.getParagraph( paraId );
+ final var text = paragraph.getText();
+
+ // Ensure that styles aren't doubled-up.
+ editor.clearStyle( paraId );
+
+ spellcheck( editor, text, paraId );
+ } );
+ }
+
+ /**
+ * Listen for new tab selection events.
+ */
+ private void initTabChangedListener() {
+ final FileEditorTabPane editorPane = getFileEditorPane();
+
+ // Update the preview pane changing tabs.
+ editorPane.addTabSelectionListener(
+ ( __, oldTab, newTab ) -> {
+ if( newTab == null ) {
+ // Clear the preview pane when closing an editor. When the last
+ // tab is closed, this ensures that the preview pane is empty.
+ getHtmlPreview().clear();
+ }
+ else {
+ final var tab = (FileEditorTab) newTab;
+ updateVariableNameInjector( tab );
+ process( tab );
+ }
+ }
+ );
+ }
+
+ /**
+ * Reloads the preferences from the previous session.
+ */
+ private void initPreferences() {
+ initDefinitionPane();
+ getFileEditorPane().initPreferences();
+ getUserPreferencesView().addSaveEventHandler( mRPreferencesListener );
+ }
+
+ private void initVariableNameInjector() {
+ updateVariableNameInjector( getActiveFileEditorTab() );
+ }
+
+ /**
+ * Calls the listener when the given node is shown for the first time. The
+ * visible property is not the same as the initial showing event; visibility
+ * can be triggered numerous times (such as going off screen).
+ * <p>
+ * This is called, for example, before the drag handler can be attached,
+ * because the scrollbar for the text editor pane must be visible.
+ * </p>
+ *
+ * @param node The node to watch for showing.
+ * @param consumer The consumer to invoke when the event fires.
+ */
+ private void addShowListener(
+ final Node node, final Consumer<Void> consumer ) {
+ final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
+ runLater( () -> {
+ if( newShow != null && newShow ) {
+ try {
+ consumer.accept( null );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+ } );
+
+ Val.flatMap( node.sceneProperty(), Scene::windowProperty )
+ .flatMap( Window::showingProperty )
+ .addListener( listener );
+ }
+
+ private void scrollToCaret() {
+ synchronized( mMutex ) {
+ getHtmlPreview().scrollTo( CARET_ID );
+ }
+ }
+
+ private void updateVariableNameInjector( final FileEditorTab tab ) {
+ getDefinitionNameInjector().addListener( tab );
+ }
+
+ /**
+ * Called to update the status bar's caret position when a new tab is added
+ * or the active tab is switched.
+ *
+ * @param tab The active tab containing a caret position to show.
+ */
+ private void updateCaretStatus( final FileEditorTab tab ) {
+ getLineNumberText().setText( tab.getCaretPosition().toString() );
+ }
+
+ /**
+ * Called whenever the preview pane becomes out of sync with the file editor
+ * tab. This can be called when the text changes, the caret paragraph
+ * changes, or the file tab changes.
+ *
+ * @param tab The file editor tab that has been changed in some fashion.
+ */
+ private void process( final FileEditorTab tab ) {
+ if( tab != null ) {
+ getHtmlPreview().setPath( tab.getPath() );
+
+ final Processor<String> processor = getProcessors().computeIfAbsent(
+ tab, p -> createProcessors( tab )
+ );
+
+ try {
+ updateCaretStatus( tab );
+ processChain( processor, tab.getEditorText() );
+ scrollToCaret();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+ }
+
+ private void processActiveTab() {
+ process( getActiveFileEditorTab() );
+ }
+
+ /**
+ * Called when a definition source is opened.
+ *
+ * @param path Path to the definition source that was opened.
+ */
+ private void openDefinitions( final Path path ) {
+ try {
+ final var ds = createDefinitionSource( path );
+ setDefinitionSource( ds );
+
+ final var prefs = getUserPreferencesView();
+ prefs.definitionPathProperty().setValue( path.toFile() );
+ prefs.save();
+
+ final var tooltipPath = new Tooltip( path.toString() );
+ tooltipPath.setShowDelay( Duration.millis( 200 ) );
+
+ final var pane = getDefinitionPane();
+ pane.update( ds );
+ pane.addTreeChangeHandler( mTreeHandler );
+ pane.addKeyEventHandler( mDefinitionKeyHandler );
+ pane.filenameProperty().setValue( path.getFileName().toString() );
+ pane.setTooltip( tooltipPath );
+
+ interpolateResolvedMap();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void exportDefinitions( final Path path ) {
+ try {
+ final var pane = getDefinitionPane();
+ final var root = pane.getTreeView().getRoot();
+ final var problemChild = pane.isTreeWellFormed();
+
+ if( problemChild == null ) {
+ getDefinitionSource().getTreeAdapter().export( root, path );
+ }
+ else {
+ clue( "yaml.error.tree.form", problemChild.getValue() );
+ }
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void interpolateResolvedMap() {
+ final var treeMap = getDefinitionPane().toMap();
+ final var map = new HashMap<>( treeMap );
+ MapInterpolator.interpolate( map );
+
+ getResolvedMap().clear();
+ getResolvedMap().putAll( map );
+ }
+
+ private void initDefinitionPane() {
+ openDefinitions( getDefinitionPath() );
+ }
+
+ //---- File actions -------------------------------------------------------
+
+ /**
+ * Called when an {@link Observable} instance has changed. This is called
+ * by both the {@link Snitch} service and the notify service. The @link
+ * Snitch} service can be called for different file types, including
+ * {@link DefinitionSource} instances.
+ *
+ * @param observable The observed instance.
+ * @param value The noteworthy item.
+ */
+ @Override
+ public void update( final Observable observable, final Object value ) {
+ if( value instanceof Path && observable instanceof Snitch ) {
+ updateSelectedTab();
+ }
+ }
+
+ /**
+ * Called when a file has been modified.
+ */
+ private void updateSelectedTab() {
+ rerender();
+ }
+
+ /**
+ * After resetting the processors, they will refresh anew to be up-to-date
+ * with the files (text and definition) currently loaded into the editor.
+ */
+ private void resetProcessors() {
+ getProcessors().clear();
+ }
+
+ //---- File actions -------------------------------------------------------
+
+ private void fileNew() {
+ getFileEditorPane().newEditor();
+ }
+
+ private void fileOpen() {
+ getFileEditorPane().openFileDialog();
+ }
+
+ private void fileClose() {
+ getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
+ }
+
+ /**
+ * TODO: Upon closing, first remove the tab change listeners. (There's no
+ * need to re-render each tab when all are being closed.)
+ */
+ private void fileCloseAll() {
+ getFileEditorPane().closeAllEditors();
+ }
+
+ private void fileSaveAs() {
+ final FileEditorTab editor = getActiveFileEditorTab();
+ getFileEditorPane().saveEditorAs( editor );
+ getProcessors().remove( editor );
+
+ try {
+ process( editor );
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ private void fileSaveAll() {
+ getFileEditorPane().saveAllEditors();
+ }
+
+ /**
+ * Exports the contents of the current tab according to the given
+ * {@link ExportFormat}.
+ *
+ * @param format Configures the {@link MarkdownProcessor} when exporting.
+ */
+ private void fileExport( final ExportFormat format ) {
+ final var tab = getActiveFileEditorTab();
+ final var context = createProcessorContext( tab, format );
+ final var chain = ProcessorFactory.createProcessors( context );
+ final var doc = tab.getEditorText();
+ final var export = processChain( chain, doc );
+
+ final var filename = format.toExportFilename( tab.getPath().toFile() );
+ final var dir = getPreferences().get( "lastDirectory", null );
+ final var lastDir = new File( dir == null ? "." : dir );
+
+ final FileChooser chooser = new FileChooser();
+ chooser.setTitle( get( "Dialog.file.choose.export.title" ) );
+ chooser.setInitialFileName( filename.getName() );
+ chooser.setInitialDirectory( lastDir );
+
+ final File file = chooser.showSaveDialog( getWindow() );
+
+ if( file != null ) {
+ try {
+ writeString( file.toPath(), export, UTF_8 );
+ final var m = get( "Main.status.export.success", file.toString() );
+ clue( m );
+ } catch( final IOException e ) {
+ clue( e );
+ }
+ }
+ }
+
+ private void fileExit() {
+ final Window window = getWindow();
+ fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
+ }
+
+ //---- Edit actions -------------------------------------------------------
+
+ /**
+ * Used to find text in the active file editor window.
+ */
+ private void editFind() {
+ final TextField input = getFindTextField();
+ getStatusBar().setGraphic( input );
+ input.requestFocus();
+ }
+
+ public void editFindNext() {
+ getActiveFileEditorTab().searchNext( getFindTextField().getText() );
+ }
+
+ public void editPreferences() {
+ getUserPreferencesView().show();
+ }
+
+ //---- Insert actions -----------------------------------------------------
+
+ /**
+ * Delegates to the active editor to handle wrapping the current text
+ * selection with leading and trailing strings.
+ *
+ * @param leading The string to put before the selection.
+ * @param trailing The string to put after the selection.
+ */
+ private void insertMarkdown(
+ final String leading, final String trailing ) {
+ getActiveEditorPane().surroundSelection( leading, trailing );
+ }
+
+ private void insertMarkdown(
+ final String leading, final String trailing, final String hint ) {
+ getActiveEditorPane().surroundSelection( leading, trailing, hint );
+ }
+
+ //---- Help actions -------------------------------------------------------
+
+ private void helpAbout() {
+ final Alert alert = new Alert( INFORMATION );
+ alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
+ alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
+ alert.setContentText( get( "Dialog.about.content" ) );
+ alert.setGraphic( new ImageView( ICON_DIALOG ) );
+ alert.initOwner( getWindow() );
+
+ alert.showAndWait();
+ }
+
+ //---- Member creators ----------------------------------------------------
+
+ private SpellChecker createSpellChecker() {
+ try {
+ final Collection<String> lexicon = readLexicon( "en.txt" );
+ return SymSpellSpeller.forLexicon( lexicon );
+ } catch( final Exception ex ) {
+ clue( ex );
+ return new PermissiveSpeller();
+ }
+ }
+
+ /**
+ * Creates processors suited to parsing and rendering different file types.
+ *
+ * @param tab The tab that is subjected to processing.
+ * @return A processor suited to the file type specified by the tab's path.
+ */
+ private Processor<String> createProcessors( final FileEditorTab tab ) {
+ final var context = createProcessorContext( tab );
+ return ProcessorFactory.createProcessors( context );
+ }
+
+ private ProcessorContext createProcessorContext(
+ final FileEditorTab tab, final ExportFormat format ) {
+ final var preview = getHtmlPreview();
+ final var map = getResolvedMap();
+ return new ProcessorContext( preview, map, tab, format );
+ }
+
+ private ProcessorContext createProcessorContext( final FileEditorTab tab ) {
+ return createProcessorContext( tab, NONE );
+ }
+
+ private DefinitionPane createDefinitionPane() {
+ return new DefinitionPane();
+ }
+
+ private OutputTabPane createOutputTabPane() {
+ return new OutputTabPane();
+ }
+
+ private DefinitionSource createDefaultDefinitionSource() {
+ return new YamlDefinitionSource( getDefinitionPath() );
+ }
+
+ private DefinitionSource createDefinitionSource( final Path path ) {
+ try {
+ return createDefinitionFactory().createDefinitionSource( path );
+ } catch( final Exception ex ) {
+ clue( ex );
+ return createDefaultDefinitionSource();
+ }
+ }
+
+ private TextField createFindTextField() {
+ return new TextField();
+ }
+
+ private DefinitionFactory createDefinitionFactory() {
+ return new DefinitionFactory();
+ }
+
+ private StatusBar createStatusBar() {
+ return new StatusBar();
+ }
+
+ private Scene createScene() {
+ final var splitPane = new SplitPane(
+ getDefinitionPane(),
+ getFileEditorPane(),
+ getOutputPane() );
+
+ splitPane.setDividerPositions(
+ getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
+ getFloat( K_PANE_SPLIT_EDITOR, .60f ),
+ getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
+
+ getDefinitionPane().prefHeightProperty()
+ .bind( splitPane.heightProperty() );
+
+ final BorderPane borderPane = new BorderPane();
+ borderPane.setPrefSize( 1280, 800 );
+ borderPane.setTop( createMenuBar() );
+ borderPane.setBottom( getStatusBar() );
+ borderPane.setCenter( splitPane );
+
+ final VBox statusBar = new VBox();
+ statusBar.setAlignment( Pos.BASELINE_CENTER );
+ statusBar.getChildren().add( getLineNumberText() );
+ getStatusBar().getRightItems().add( statusBar );
+
+ // Force preview pane refresh on Windows.
+ if( SystemUtils.IS_OS_WINDOWS ) {
+ splitPane.getDividers().get( 1 ).positionProperty().addListener(
+ ( l, oValue, nValue ) -> runLater(
+ () -> getHtmlPreview().repaintScrollPane()
+ )
+ );
+ }
+
+ return new Scene( borderPane );
+ }
+
+ private Text createLineNumberText() {
+ return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
+ }
+
+ private Node createMenuBar() {
+ final BooleanBinding activeFileEditorIsNull =
+ getFileEditorPane().activeFileEditorProperty().isNull();
+
+ // File actions
+ final Action fileNewAction = Action
+ .builder()
+ .setText( "Main.menu.file.new" )
+ .setAccelerator( "Shortcut+N" )
+ .setIcon( FILE_ALT )
+ .setAction( e -> fileNew() )
+ .build();
+ final Action fileOpenAction = Action
+ .builder()
+ .setText( "Main.menu.file.open" )
+ .setAccelerator( "Shortcut+O" )
+ .setIcon( FOLDER_OPEN_ALT )
+ .setAction( e -> fileOpen() )
+ .build();
+ final Action fileCloseAction = Action
+ .builder()
+ .setText( "Main.menu.file.close" )
+ .setAccelerator( "Shortcut+W" )
+ .setAction( e -> fileClose() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action fileCloseAllAction = Action
+ .builder()
+ .setText( "Main.menu.file.close_all" )
+ .setAction( e -> fileCloseAll() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action fileSaveAction = Action
+ .builder()
+ .setText( "Main.menu.file.save" )
+ .setAccelerator( "Shortcut+S" )
+ .setIcon( FLOPPY_ALT )
+ .setAction( e -> getActiveFileEditorTab().save() )
+ .setDisabled( createActiveBooleanProperty(
+ FileEditorTab::modifiedProperty ).not() )
+ .build();
+ final Action fileSaveAsAction = Action
+ .builder()
+ .setText( "Main.menu.file.save_as" )
+ .setAction( e -> fileSaveAs() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action fileSaveAllAction = Action
+ .builder()
+ .setText( "Main.menu.file.save_all" )
+ .setAccelerator( "Shortcut+Shift+S" )
+ .setAction( e -> fileSaveAll() )
+ .setDisabled( Bindings.not(
+ getFileEditorPane().anyFileEditorModifiedProperty() ) )
+ .build();
+ final Action fileExportAction = Action
+ .builder()
+ .setText( "Main.menu.file.export" )
+ .build();
+ final Action fileExportHtmlSvgAction = Action
+ .builder()
+ .setText( "Main.menu.file.export.html_svg" )
+ .setAction( e -> fileExport( HTML_TEX_SVG ) )
+ .build();
+ final Action fileExportHtmlTexAction = Action
+ .builder()
+ .setText( "Main.menu.file.export.html_tex" )
+ .setAction( e -> fileExport( HTML_TEX_DELIMITED ) )
+ .build();
+ final Action fileExportMarkdownAction = Action
+ .builder()
+ .setText( "Main.menu.file.export.markdown" )
+ .setAction( e -> fileExport( MARKDOWN_PLAIN ) )
+ .build();
+ fileExportAction.addSubActions(
+ fileExportHtmlSvgAction,
+ fileExportHtmlTexAction,
+ fileExportMarkdownAction );
+
+ final Action fileExitAction = Action
+ .builder()
+ .setText( "Main.menu.file.exit" )
+ .setAction( e -> fileExit() )
+ .build();
+
+ // Edit actions
+ final Action editUndoAction = Action
+ .builder()
+ .setText( "Main.menu.edit.undo" )
+ .setAccelerator( "Shortcut+Z" )
+ .setIcon( UNDO )
+ .setAction( e -> getActiveEditorPane().undo() )
+ .setDisabled( createActiveBooleanProperty(
+ FileEditorTab::canUndoProperty ).not() )
+ .build();
+ final Action editRedoAction = Action
+ .builder()
+ .setText( "Main.menu.edit.redo" )
+ .setAccelerator( "Shortcut+Y" )
+ .setIcon( REPEAT )
+ .setAction( e -> getActiveEditorPane().redo() )
+ .setDisabled( createActiveBooleanProperty(
+ FileEditorTab::canRedoProperty ).not() )
+ .build();
+
+ final Action editCutAction = Action
+ .builder()
+ .setText( "Main.menu.edit.cut" )
+ .setAccelerator( "Shortcut+X" )
+ .setIcon( CUT )
+ .setAction( e -> getActiveEditorPane().cut() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editCopyAction = Action
+ .builder()
+ .setText( "Main.menu.edit.copy" )
+ .setAccelerator( "Shortcut+C" )
+ .setIcon( COPY )
+ .setAction( e -> getActiveEditorPane().copy() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editPasteAction = Action
+ .builder()
+ .setText( "Main.menu.edit.paste" )
+ .setAccelerator( "Shortcut+V" )
+ .setIcon( PASTE )
+ .setAction( e -> getActiveEditorPane().paste() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editSelectAllAction = Action
+ .builder()
+ .setText( "Main.menu.edit.selectAll" )
+ .setAccelerator( "Shortcut+A" )
+ .setAction( e -> getActiveEditorPane().selectAll() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ final Action editFindAction = Action
+ .builder()
+ .setText( "Main.menu.edit.find" )
+ .setAccelerator( "Ctrl+F" )
+ .setIcon( SEARCH )
+ .setAction( e -> editFind() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editFindNextAction = Action
+ .builder()
+ .setText( "Main.menu.edit.find.next" )
+ .setAccelerator( "F3" )
+ .setAction( e -> editFindNext() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action editPreferencesAction = Action
+ .builder()
+ .setText( "Main.menu.edit.preferences" )
+ .setAccelerator( "Ctrl+Alt+S" )
+ .setAction( e -> editPreferences() )
+ .build();
+
+ // Format actions
+ final Action formatBoldAction = Action
+ .builder()
+ .setText( "Main.menu.format.bold" )
+ .setAccelerator( "Shortcut+B" )
+ .setIcon( BOLD )
+ .setAction( e -> insertMarkdown( "**", "**" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatItalicAction = Action
+ .builder()
+ .setText( "Main.menu.format.italic" )
+ .setAccelerator( "Shortcut+I" )
+ .setIcon( ITALIC )
+ .setAction( e -> insertMarkdown( "*", "*" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatSuperscriptAction = Action
+ .builder()
+ .setText( "Main.menu.format.superscript" )
+ .setAccelerator( "Shortcut+[" )
+ .setIcon( SUPERSCRIPT )
+ .setAction( e -> insertMarkdown( "^", "^" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatSubscriptAction = Action
+ .builder()
+ .setText( "Main.menu.format.subscript" )
+ .setAccelerator( "Shortcut+]" )
+ .setIcon( SUBSCRIPT )
+ .setAction( e -> insertMarkdown( "~", "~" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action formatStrikethroughAction = Action
+ .builder()
+ .setText( "Main.menu.format.strikethrough" )
+ .setAccelerator( "Shortcut+T" )
+ .setIcon( STRIKETHROUGH )
+ .setAction( e -> insertMarkdown( "~~", "~~" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ // Insert actions
+ final Action insertBlockquoteAction = Action
+ .builder()
+ .setText( "Main.menu.insert.blockquote" )
+ .setAccelerator( "Ctrl+Q" )
+ .setIcon( QUOTE_LEFT )
+ .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertCodeAction = Action
+ .builder()
+ .setText( "Main.menu.insert.code" )
+ .setAccelerator( "Shortcut+K" )
+ .setIcon( CODE )
+ .setAction( e -> insertMarkdown( "`", "`" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertFencedCodeBlockAction = Action
+ .builder()
+ .setText( "Main.menu.insert.fenced_code_block" )
+ .setAccelerator( "Shortcut+Shift+K" )
+ .setIcon( FILE_CODE_ALT )
+ .setAction( e -> insertMarkdown(
+ "\n\n```\n",
+ "\n```\n\n",
+ get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertLinkAction = Action
+ .builder()
+ .setText( "Main.menu.insert.link" )
+ .setAccelerator( "Shortcut+L" )
+ .setIcon( LINK )
+ .setAction( e -> getActiveEditorPane().insertLink() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertImageAction = Action
+ .builder()
+ .setText( "Main.menu.insert.image" )
+ .setAccelerator( "Shortcut+G" )
+ .setIcon( PICTURE_ALT )
+ .setAction( e -> getActiveEditorPane().insertImage() )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ // Number of heading actions (H1 ... H3)
+ final int HEADINGS = 3;
+ final Action[] headings = new Action[ HEADINGS ];
+
+ for( int i = 1; i <= HEADINGS; i++ ) {
+ final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
+ final String markup = String.format( "%n%n%s ", hashes );
+ final String text = "Main.menu.insert.heading." + i;
+ final String accelerator = "Shortcut+" + i;
+ final String prompt = text + ".prompt";
+
+ headings[ i - 1 ] = Action
+ .builder()
+ .setText( text )
+ .setAccelerator( accelerator )
+ .setIcon( HEADER )
+ .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ }
+
+ final Action insertUnorderedListAction = Action
+ .builder()
+ .setText( "Main.menu.insert.unordered_list" )
+ .setAccelerator( "Shortcut+U" )
+ .setIcon( LIST_UL )
+ .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertOrderedListAction = Action
+ .builder()
+ .setText( "Main.menu.insert.ordered_list" )
+ .setAccelerator( "Shortcut+Shift+O" )
+ .setIcon( LIST_OL )
+ .setAction( e -> insertMarkdown(
+ "\n\n1. ", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+ final Action insertHorizontalRuleAction = Action
+ .builder()
+ .setText( "Main.menu.insert.horizontal_rule" )
+ .setAccelerator( "Shortcut+H" )
+ .setAction( e -> insertMarkdown(
+ "\n\n---\n\n", "" ) )
+ .setDisabled( activeFileEditorIsNull )
+ .build();
+
+ // Definition actions
+ final Action definitionCreateAction = Action
+ .builder()
+ .setText( "Main.menu.definition.create" )
+ .setIcon( TREE )
+ .setAction( e -> getDefinitionPane().addItem() )
+ .build();
+ final Action definitionInsertAction = Action
+ .builder()
+ .setText( "Main.menu.definition.insert" )
+ .setAccelerator( "Ctrl+Space" )
+ .setIcon( STAR )
+ .setAction( e -> definitionInsert() )
+ .build();
+
+ // Help actions
+ final Action helpAboutAction = Action
+ .builder()
+ .setText( "Main.menu.help.about" )
+ .setAction( e -> helpAbout() )
+ .build();
+
+ final Action SEPARATOR_ACTION = new SeparatorAction();
+
+ //---- MenuBar ----
+
+ // File Menu
+ final var fileMenu = ActionUtils.createMenu(
+ get( "Main.menu.file" ),
+ fileNewAction,
+ fileOpenAction,
+ SEPARATOR_ACTION,
+ fileCloseAction,
+ fileCloseAllAction,
+ SEPARATOR_ACTION,
+ fileSaveAction,
+ fileSaveAsAction,
+ fileSaveAllAction,
+ SEPARATOR_ACTION,
+ fileExportAction,
+ SEPARATOR_ACTION,
+ fileExitAction );
+
+ // Edit Menu
+ final var editMenu = ActionUtils.createMenu(
+ get( "Main.menu.edit" ),
+ SEPARATOR_ACTION,
+ editUndoAction,
+ editRedoAction,
+ SEPARATOR_ACTION,
+ editCutAction,
+ editCopyAction,
+ editPasteAction,
+ editSelectAllAction,
+ SEPARATOR_ACTION,
+ editFindAction,
+ editFindNextAction,
+ SEPARATOR_ACTION,
+ editPreferencesAction );
+
+ // Format Menu
+ final var formatMenu = ActionUtils.createMenu(
+ get( "Main.menu.format" ),
+ formatBoldAction,
+ formatItalicAction,
+ formatSuperscriptAction,
+ formatSubscriptAction,
+ formatStrikethroughAction
+ );
+
+ // Insert Menu
+ final var insertMenu = ActionUtils.createMenu(
+ get( "Main.menu.insert" ),
+ insertBlockquoteAction,
+ insertCodeAction,
+ insertFencedCodeBlockAction,
+ SEPARATOR_ACTION,
+ insertLinkAction,
+ insertImageAction,
+ SEPARATOR_ACTION,
+ headings[ 0 ],
+ headings[ 1 ],
+ headings[ 2 ],
+ SEPARATOR_ACTION,
+ insertUnorderedListAction,
+ insertOrderedListAction,
+ insertHorizontalRuleAction
+ );
+
+ // Definition Menu
+ final var definitionMenu = ActionUtils.createMenu(
+ get( "Main.menu.definition" ),
+ definitionCreateAction,
+ definitionInsertAction );
+
+ // Help Menu
+ final var helpMenu = ActionUtils.createMenu(
+ get( "Main.menu.help" ),
+ helpAboutAction );
+
+ //---- MenuBar ----
+ final var menuBar = new MenuBar(
+ fileMenu,
+ editMenu,
+ formatMenu,
+ insertMenu,
+ definitionMenu,
+ helpMenu );
+
+ //---- ToolBar ----
+ final var toolBar = ActionUtils.createToolBar(
+ fileNewAction,
+ fileOpenAction,
+ fileSaveAction,
+ SEPARATOR_ACTION,
+ editUndoAction,
+ editRedoAction,
+ editCutAction,
+ editCopyAction,
+ editPasteAction,
+ SEPARATOR_ACTION,
+ formatBoldAction,
+ formatItalicAction,
+ formatSuperscriptAction,
+ formatSubscriptAction,
+ insertBlockquoteAction,
+ insertCodeAction,
+ insertFencedCodeBlockAction,
+ SEPARATOR_ACTION,
+ insertLinkAction,
+ insertImageAction,
+ SEPARATOR_ACTION,
+ headings[ 0 ],
+ SEPARATOR_ACTION,
+ insertUnorderedListAction,
+ insertOrderedListAction );
+
+ return new VBox( menuBar, toolBar );
+ }
+
+ /**
+ * Performs the autoinsert function on the active file editor.
+ */
+ private void definitionInsert() {
+ getDefinitionNameInjector().autoinsert();
+ }
+
+ /**
+ * Creates a boolean property that is bound to another boolean value of the
+ * active editor.
+ */
+ private BooleanProperty createActiveBooleanProperty(
+ final Function<FileEditorTab, ObservableBooleanValue> func ) {
+
+ final BooleanProperty b = new SimpleBooleanProperty();
+ final FileEditorTab tab = getActiveFileEditorTab();
+
+ if( tab != null ) {
+ b.bind( func.apply( tab ) );
+ }
+
+ getFileEditorPane().activeFileEditorProperty().addListener(
+ ( __, oldFileEditor, newFileEditor ) -> {
+ b.unbind();
+
+ if( newFileEditor == null ) {
+ b.set( false );
+ }
+ else {
+ b.bind( func.apply( newFileEditor ) );
+ }
+ }
+ );
+
+ return b;
+ }
+
+ //---- Convenience accessors ----------------------------------------------
+
+ private Preferences getPreferences() {
+ return sOptions.getState();
+ }
+
+ private float getFloat( final String key, final float defaultValue ) {
+ return getPreferences().getFloat( key, defaultValue );
+ }
+
+ public Window getWindow() {
+ return getScene().getWindow();
+ }
+
+ private MarkdownEditorPane getActiveEditorPane() {
+ return getActiveFileEditorTab().getEditorPane();
+ }
+
+ private FileEditorTab getActiveFileEditorTab() {
+ return getFileEditorPane().getActiveFileEditor();
+ }
+
+ //---- Member accessors ---------------------------------------------------
+
+ protected Scene getScene() {
+ return mScene;
+ }
+
+ private SpellChecker getSpellChecker() {
+ return mSpellChecker;
+ }
+
+ private Map<FileEditorTab, Processor<String>> getProcessors() {
+ return mProcessors;
+ }
+
+ private FileEditorTabPane getFileEditorPane() {
+ return mFileEditorPane;
+ }
+
+ private OutputTabPane getOutputPane() {
+ return mOutputPane;
+ }
+
+ private HtmlPreview getHtmlPreview() {
+ return getOutputPane().getHtmlPreview();
+ }
+
+ private void setDefinitionSource(
+ final DefinitionSource definitionSource ) {
+ assert definitionSource != null;
+ mDefinitionSource = definitionSource;
+ }
+
+ private DefinitionSource getDefinitionSource() {
+ return mDefinitionSource;
+ }
+
+ private DefinitionPane getDefinitionPane() {
+ return mDefinitionPane;
+ }
+
+ private Text getLineNumberText() {
+ return mLineNumberText;
+ }
+
+ private StatusBar getStatusBar() {
+ return StatusBarNotifier.getStatusBar();
+ }
+
+ private TextField getFindTextField() {
+ return mFindTextField;
+ }
+
+ private DefinitionNameInjector getDefinitionNameInjector() {
+ return mDefinitionNameInjector;
+ }
+
+ /**
+ * Returns the variable map of interpolated definitions.
+ *
+ * @return A map to help dereference variables.
+ */
+ private Map<String, String> getResolvedMap() {
+ return mResolvedMap;
+ }
+
+ //---- Persistence accessors ----------------------------------------------
+
+ private UserPreferences getUserPreferences() {
+ return UserPreferences.getInstance();
+ }
+
+ private UserPreferencesView getUserPreferencesView() {
+ return UserPreferencesView.getInstance();
}
src/main/java/com/keenwrite/StatusBarNotifier.java
private static final Notifier sNotifier = Services.load( Notifier.class );
- private static StatusBar sStatusBar;
-
- public static void setStatusBar( final StatusBar statusBar ) {
- sStatusBar = statusBar;
- }
+ private static final StatusBar sStatusBar = new StatusBar();
/**
public static Notifier getNotifier() {
return sNotifier;
+ }
+
+ public static StatusBar getStatusBar() {
+ return sStatusBar;
}
src/main/java/com/keenwrite/preferences/UserPreferences.java
package com.keenwrite.preferences;
-import com.dlsc.formsfx.model.structure.StringField;
import com.dlsc.preferencesfx.PreferencesFx;
-import com.dlsc.preferencesfx.PreferencesFxEvent;
-import com.dlsc.preferencesfx.model.Category;
-import com.dlsc.preferencesfx.model.Group;
-import com.dlsc.preferencesfx.model.Setting;
import javafx.beans.property.*;
-import javafx.event.EventHandler;
-import javafx.scene.Node;
-import javafx.scene.control.Label;
import java.io.File;
import java.nio.file.Path;
import static com.keenwrite.Constants.*;
-import static com.keenwrite.Messages.get;
/**
* Responsible for user preferences that can be changed from the GUI. The
* settings are displayed and persisted using {@link PreferencesFx}.
*/
public class UserPreferences {
/**
- * Implementation of the initialization-on-demand holder design pattern,
+ * Implementation of the initialization-on-demand holder design pattern,
* an for a lazy-loaded singleton. In all versions of Java, the idiom enables
* a safe, highly concurrent lazy initialization of static fields with good
* performance. The implementation relies upon the initialization phase of
* execution within the Java Virtual Machine (JVM) as specified by the Java
- * Language Specification. When the class {@link UserPreferencesContainer}
- * is loaded, its initialization completes trivially because there are no
- * static variables to initialize.
- * <p>
- * The static class definition {@link UserPreferencesContainer} within the
- * {@link UserPreferences} is not initialized until such time that
- * {@link UserPreferencesContainer} must be executed. The static
- * {@link UserPreferencesContainer} class executes when
- * {@link #getInstance} is called. The first call will trigger loading and
- * initialization of the {@link UserPreferencesContainer} thereby
- * instantiating the {@link #INSTANCE}.
- * </p>
- * <p>
- * This indirection is necessary because the {@link UserPreferences} class
- * references {@link PreferencesFx}, which must not be instantiated until the
- * UI is ready.
- * </p>
+ * Language Specification.
*/
private static class UserPreferencesContainer {
- private static final UserPreferences INSTANCE = new UserPreferences();
+ private final static UserPreferences INSTANCE = new UserPreferences();
}
+ /**
+ * Returns the singleton instance for rendering math symbols.
+ *
+ * @return A non-null instance, loaded, configured, and ready to render math.
+ */
public static UserPreferences getInstance() {
return UserPreferencesContainer.INSTANCE;
}
-
- private final PreferencesFx mPreferencesFx;
private final ObjectProperty<File> mPropRDirectory;
mPropFontsSizeEditor = new SimpleIntegerProperty( (int) FONT_SIZE_EDITOR );
-
- // All properties must be initialized before creating the dialog.
- mPreferencesFx = createPreferencesFx();
- }
-
- /**
- * Display the user preferences settings dialog (non-modal).
- */
- public void show() {
- getPreferencesFx().show( false );
- }
-
- /**
- * Call to persist the settings. Strictly speaking, this could watch on
- * all values for external changes then save automatically.
- */
- public void save() {
- getPreferencesFx().saveSettings();
- }
-
- /**
- * Creates the preferences dialog.
- * <p>
- * TODO: Make this dynamic by iterating over all "Preferences.*" values
- * that follow a particular naming pattern.
- * </p>
- *
- * @return A new instance of preferences for users to edit.
- */
- @SuppressWarnings("unchecked")
- private PreferencesFx createPreferencesFx() {
- final Setting<StringField, StringProperty> scriptSetting =
- Setting.of( "Script", mPropRScript );
- final StringField field = scriptSetting.getElement();
- field.multiline( true );
-
- return PreferencesFx.of(
- UserPreferences.class,
- Category.of(
- get( "Preferences.r" ),
- Group.of(
- get( "Preferences.r.directory" ),
- Setting.of( label( "Preferences.r.directory.desc", false ) ),
- Setting.of( "Directory", mPropRDirectory, true )
- ),
- Group.of(
- get( "Preferences.r.script" ),
- Setting.of( label( "Preferences.r.script.desc" ) ),
- scriptSetting
- ),
- Group.of(
- get( "Preferences.r.delimiter.began" ),
- Setting.of( label( "Preferences.r.delimiter.began.desc" ) ),
- Setting.of( "Opening", mPropRDelimBegan )
- ),
- Group.of(
- get( "Preferences.r.delimiter.ended" ),
- Setting.of( label( "Preferences.r.delimiter.ended.desc" ) ),
- Setting.of( "Closing", mPropRDelimEnded )
- )
- ),
- Category.of(
- get( "Preferences.images" ),
- Group.of(
- get( "Preferences.images.directory" ),
- Setting.of( label( "Preferences.images.directory.desc" ) ),
- Setting.of( "Directory", mPropImagesDirectory, true )
- ),
- Group.of(
- get( "Preferences.images.suffixes" ),
- Setting.of( label( "Preferences.images.suffixes.desc" ) ),
- Setting.of( "Extensions", mPropImagesOrder )
- )
- ),
- Category.of(
- get( "Preferences.definitions" ),
- Group.of(
- get( "Preferences.definitions.path" ),
- Setting.of( label( "Preferences.definitions.path.desc" ) ),
- Setting.of( "Path", mPropDefinitionPath, false )
- ),
- Group.of(
- get( "Preferences.definitions.delimiter.began" ),
- Setting.of( label(
- "Preferences.definitions.delimiter.began.desc" ) ),
- Setting.of( "Opening", mPropDefDelimBegan )
- ),
- Group.of(
- get( "Preferences.definitions.delimiter.ended" ),
- Setting.of( label(
- "Preferences.definitions.delimiter.ended.desc" ) ),
- Setting.of( "Closing", mPropDefDelimEnded )
- )
- ),
- Category.of(
- get( "Preferences.fonts" ),
- Group.of(
- get( "Preferences.fonts.size_editor" ),
- Setting.of( label( "Preferences.fonts.size_editor.desc" ) ),
- Setting.of( "Points", mPropFontsSizeEditor )
- )
- )
- ).instantPersistent( false )
- .dialogIcon( ICON_DIALOG );
}
private SimpleObjectProperty<File> simpleFile( final String path ) {
return new SimpleObjectProperty<>( new File( path ) );
- }
-
- /**
- * Creates a label for the given key after interpolating its value.
- *
- * @param key The key to find in the resource bundle.
- * @return The value of the key as a label.
- */
- private Node label( final String key ) {
- return new Label( get( key, true ) );
- }
-
- /**
- * Creates a label for the given key.
- *
- * @param key The key to find in the resource bundle.
- * @param interpolate {@code true} means to interpolate the value.
- * @return The value of the key, interpolated if {@code interpolate} is
- * {@code true}.
- */
- @SuppressWarnings("SameParameterValue")
- private Node label( final String key, final boolean interpolate ) {
- return new Label( get( key, interpolate ) );
- }
-
- /**
- * Delegates to the {@link PreferencesFx} event handler for monitoring
- * save events.
- *
- * @param eventHandler The handler to call when the preferences are saved.
- */
- public void addSaveEventHandler(
- final EventHandler<? super PreferencesFxEvent> eventHandler ) {
- final var eventType = PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
- getPreferencesFx().addEventHandler( eventType, eventHandler );
}
}
- private StringProperty defDelimiterBegan() {
+ public StringProperty defDelimiterBeganProperty() {
return mPropDefDelimBegan;
}
public String getDefDelimiterBegan() {
- return defDelimiterBegan().get();
+ return defDelimiterBeganProperty().get();
}
- private StringProperty defDelimiterEnded() {
+ public StringProperty defDelimiterEndedProperty() {
return mPropDefDelimEnded;
}
public String getDefDelimiterEnded() {
- return defDelimiterEnded().get();
+ return defDelimiterEndedProperty().get();
}
}
- private StringProperty rDelimiterBegan() {
+ public StringProperty rDelimiterBeganProperty() {
return mPropRDelimBegan;
}
public String getRDelimiterBegan() {
- return rDelimiterBegan().get();
+ return rDelimiterBeganProperty().get();
}
- private StringProperty rDelimiterEnded() {
+ public StringProperty rDelimiterEndedProperty() {
return mPropRDelimEnded;
}
public String getRDelimiterEnded() {
- return rDelimiterEnded().get();
+ return rDelimiterEndedProperty().get();
}
- private ObjectProperty<File> imagesDirectoryProperty() {
+ public ObjectProperty<File> imagesDirectoryProperty() {
return mPropImagesDirectory;
}
public File getImagesDirectory() {
return imagesDirectoryProperty().getValue();
}
- private StringProperty imagesOrderProperty() {
+ StringProperty imagesOrderProperty() {
return mPropImagesOrder;
}
public int getFontsSizeEditor() {
return mPropFontsSizeEditor.intValue();
- }
-
- private PreferencesFx getPreferencesFx() {
- return mPreferencesFx;
}
}
src/main/java/com/keenwrite/preferences/UserPreferencesView.java
+package com.keenwrite.preferences;
+
+import com.dlsc.formsfx.model.structure.StringField;
+import com.dlsc.preferencesfx.PreferencesFx;
+import com.dlsc.preferencesfx.PreferencesFxEvent;
+import com.dlsc.preferencesfx.model.Category;
+import com.dlsc.preferencesfx.model.Group;
+import com.dlsc.preferencesfx.model.Setting;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.StringProperty;
+import javafx.event.EventHandler;
+import javafx.scene.Node;
+import javafx.scene.control.Label;
+
+import java.io.File;
+
+import static com.keenwrite.Constants.ICON_DIALOG;
+import static com.keenwrite.Messages.get;
+
+public class UserPreferencesView {
+ /**
+ * Implementation of the initialization-on-demand holder design pattern,
+ * an for a lazy-loaded singleton. In all versions of Java, the idiom enables
+ * a safe, highly concurrent lazy initialization of static fields with good
+ * performance. The implementation relies upon the initialization phase of
+ * execution within the Java Virtual Machine (JVM) as specified by the Java
+ * Language Specification.
+ */
+ private static class UserPreferencesViewContainer {
+ private static final UserPreferencesView INSTANCE =
+ new UserPreferencesView();
+ }
+
+ /**
+ * Returns the singleton instance for rendering math symbols.
+ *
+ * @return A non-null instance, loaded, configured, and ready to render math.
+ */
+ public static UserPreferencesView getInstance() {
+ return UserPreferencesViewContainer.INSTANCE;
+ }
+
+ private final PreferencesFx mPreferencesFx;
+
+ public UserPreferencesView() {
+ // All properties must be initialized before creating the dialog.
+ mPreferencesFx = createPreferencesFx();
+ }
+
+ /**
+ * Display the user preferences settings dialog (non-modal).
+ */
+ public void show() {
+ getPreferencesFx().show( false );
+ }
+
+ /**
+ * Call to persist the settings. Strictly speaking, this could watch on
+ * all values for external changes then save automatically.
+ */
+ public void save() {
+ getPreferencesFx().saveSettings();
+ }
+
+ /**
+ * Delegates to the {@link PreferencesFx} event handler for monitoring
+ * save events.
+ *
+ * @param eventHandler The handler to call when the preferences are saved.
+ */
+ public void addSaveEventHandler(
+ final EventHandler<? super PreferencesFxEvent> eventHandler ) {
+ final var eventType = PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
+ getPreferencesFx().addEventHandler( eventType, eventHandler );
+ }
+
+ /**
+ * Creates the preferences dialog.
+ * <p>
+ * TODO: Make this dynamic by iterating over all "Preferences.*" values
+ * that follow a particular naming pattern.
+ * </p>
+ *
+ * @return A new instance of preferences for users to edit.
+ */
+ @SuppressWarnings("unchecked")
+ private PreferencesFx createPreferencesFx() {
+ final Setting<StringField, StringProperty> scriptSetting =
+ Setting.of( "Script", rScriptProperty() );
+ final StringField field = scriptSetting.getElement();
+ field.multiline( true );
+
+ return PreferencesFx.of(
+ UserPreferences.class,
+ Category.of(
+ get( "Preferences.r" ),
+ Group.of(
+ get( "Preferences.r.directory" ),
+ Setting.of( label( "Preferences.r.directory.desc", false ) ),
+ Setting.of( "Directory", rDirectoryProperty(), true )
+ ),
+ Group.of(
+ get( "Preferences.r.script" ),
+ Setting.of( label( "Preferences.r.script.desc" ) ),
+ scriptSetting
+ ),
+ Group.of(
+ get( "Preferences.r.delimiter.began" ),
+ Setting.of( label( "Preferences.r.delimiter.began.desc" ) ),
+ Setting.of( "Opening", rDelimiterBeganProperty() )
+ ),
+ Group.of(
+ get( "Preferences.r.delimiter.ended" ),
+ Setting.of( label( "Preferences.r.delimiter.ended.desc" ) ),
+ Setting.of( "Closing", rDelimiterEndedProperty() )
+ )
+ ),
+ Category.of(
+ get( "Preferences.images" ),
+ Group.of(
+ get( "Preferences.images.directory" ),
+ Setting.of( label( "Preferences.images.directory.desc" ) ),
+ Setting.of( "Directory", imagesDirectoryProperty(), true )
+ ),
+ Group.of(
+ get( "Preferences.images.suffixes" ),
+ Setting.of( label( "Preferences.images.suffixes.desc" ) ),
+ Setting.of( "Extensions", imagesOrderProperty() )
+ )
+ ),
+ Category.of(
+ get( "Preferences.definitions" ),
+ Group.of(
+ get( "Preferences.definitions.path" ),
+ Setting.of( label( "Preferences.definitions.path.desc" ) ),
+ Setting.of( "Path", definitionPathProperty(), false )
+ ),
+ Group.of(
+ get( "Preferences.definitions.delimiter.began" ),
+ Setting.of( label(
+ "Preferences.definitions.delimiter.began.desc" ) ),
+ Setting.of( "Opening", defDelimiterBeganProperty() )
+ ),
+ Group.of(
+ get( "Preferences.definitions.delimiter.ended" ),
+ Setting.of( label(
+ "Preferences.definitions.delimiter.ended.desc" ) ),
+ Setting.of( "Closing", defDelimiterEnded() )
+ )
+ ),
+ Category.of(
+ get( "Preferences.fonts" ),
+ Group.of(
+ get( "Preferences.fonts.size_editor" ),
+ Setting.of( label( "Preferences.fonts.size_editor.desc" ) ),
+ Setting.of( "Points", fontsSizeEditorProperty() )
+ )
+ )
+ ).instantPersistent( false )
+ .dialogIcon( ICON_DIALOG );
+ }
+
+ /**
+ * Creates a label for the given key after interpolating its value.
+ *
+ * @param key The key to find in the resource bundle.
+ * @return The value of the key as a label.
+ */
+ private Node label( final String key ) {
+ return new Label( get( key, true ) );
+ }
+
+ /**
+ * Creates a label for the given key.
+ *
+ * @param key The key to find in the resource bundle.
+ * @param interpolate {@code true} means to interpolate the value.
+ * @return The value of the key, interpolated if {@code interpolate} is
+ * {@code true}.
+ */
+ @SuppressWarnings("SameParameterValue")
+ private Node label( final String key, final boolean interpolate ) {
+ return new Label( get( key, interpolate ) );
+ }
+
+ private UserPreferences getUserPreferences() {
+ return UserPreferences.getInstance();
+ }
+
+ private PreferencesFx getPreferencesFx() {
+ return mPreferencesFx;
+ }
+
+ public ObjectProperty<File> definitionPathProperty() {
+ return getUserPreferences().definitionPathProperty();
+ }
+
+ private StringProperty defDelimiterBeganProperty() {
+ return getUserPreferences().defDelimiterBeganProperty();
+ }
+
+ private StringProperty defDelimiterEnded() {
+ return getUserPreferences().defDelimiterEndedProperty();
+ }
+
+ public ObjectProperty<File> rDirectoryProperty() {
+ return getUserPreferences().rDirectoryProperty();
+ }
+
+ public StringProperty rScriptProperty() {
+ return getUserPreferences().rScriptProperty();
+ }
+
+ private StringProperty rDelimiterBeganProperty() {
+ return getUserPreferences().rDelimiterBeganProperty();
+ }
+
+ private StringProperty rDelimiterEndedProperty() {
+ return getUserPreferences().rDelimiterEndedProperty();
+ }
+
+ private ObjectProperty<File> imagesDirectoryProperty() {
+ return getUserPreferences().imagesDirectoryProperty();
+ }
+
+ private StringProperty imagesOrderProperty() {
+ return getUserPreferences().imagesOrderProperty();
+ }
+
+ public IntegerProperty fontsSizeEditorProperty() {
+ return getUserPreferences().fontsSizeEditorProperty();
+ }
+}
src/main/java/com/keenwrite/preview/OutputTabPane.java
+/*
+ * Copyright 2020 White Magic Software, Ltd.
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * o Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * o Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.keenwrite.preview;
+
+import com.keenwrite.dock.control.DetachableTab;
+import com.keenwrite.dock.control.DetachableTabPane;
+
+/**
+ * Responsible for displaying output in tabs, such as {@link HtmlPreview}.
+ */
+public class OutputTabPane extends DetachableTabPane {
+ private final HtmlPreview mHtmlPreview = new HtmlPreview();
+
+ public OutputTabPane() {
+ getTabs().add( createHtmlPreviewTab() );
+ }
+
+ public HtmlPreview getHtmlPreview() {
+ return mHtmlPreview;
+ }
+
+ private DetachableTab createHtmlPreviewTab() {
+ return new DetachableTab( "HTML", getHtmlPreview() );
+ }
+}
src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
/**
- * Prevent instantiation until needed.
+ * Implementation of the initialization-on-demand holder design pattern,
+ * an for a lazy-loaded singleton. In all versions of Java, the idiom enables
+ * a safe, highly concurrent lazy initialization of static fields with good
+ * performance. The implementation relies upon the initialization phase of
+ * execution within the Java Virtual Machine (JVM) as specified by the Java
+ * Language Specification.
*/
private static class MathRendererContainer {
src/main/java/com/keenwrite/processors/InlineRProcessor.java
import com.keenwrite.preferences.UserPreferences;
+import com.keenwrite.preferences.UserPreferencesView;
import com.keenwrite.processors.markdown.MarkdownProcessor;
import com.vladsch.flexmark.ast.Paragraph;
( __, oldScript, newScript ) -> setDirty( true ) );
- getUserPreferences().addSaveEventHandler( ( handler ) -> {
+ getUserPreferencesView().addSaveEventHandler( ( handler ) -> {
if( isDirty() ) {
init();
*/
private Path getWorkingDirectory() {
- return getUserPreferences().getRDirectory().toPath();
+ return getUserPreferencesView().rDirectoryProperty().getValue().toPath();
}
private ObjectProperty<File> workingDirectoryProperty() {
- return getUserPreferences().rDirectoryProperty();
+ return getUserPreferencesView().rDirectoryProperty();
}
/**
* Loads the R init script from the application's persisted preferences.
*
* @return A non-null string, possibly empty.
*/
private String getBootstrapScript() {
- return getUserPreferences().getRScript();
+ return getUserPreferencesView().rScriptProperty().getValue();
}
private StringProperty bootstrapScriptProperty() {
- return getUserPreferences().rScriptProperty();
+ return getUserPreferencesView().rScriptProperty();
}
- private UserPreferences getUserPreferences() {
- return UserPreferences.getInstance();
+ private UserPreferencesView getUserPreferencesView() {
+ return UserPreferencesView.getInstance();
}
}
src/main/java/com/keenwrite/sigils/RSigilOperator.java
package com.keenwrite.sigils;
+import javafx.beans.property.StringProperty;
+
import static com.keenwrite.sigils.YamlSigilOperator.KEY_SEPARATOR_DEF;
public static final char SUFFIX = '`';
- private final String mDelimiterBegan =
- getUserPreferences().getRDelimiterBegan();
- private final String mDelimiterEnded =
- getUserPreferences().getRDelimiterEnded();
+ private final StringProperty mDelimiterBegan =
+ getUserPreferences().rDelimiterBeganProperty();
+ private final StringProperty mDelimiterEnded =
+ getUserPreferences().rDelimiterEndedProperty();
/**
return PREFIX
- + mDelimiterBegan
+ + mDelimiterBegan.getValue()
+ entoken( key )
- + mDelimiterEnded
+ + mDelimiterEnded.getValue()
+ SUFFIX;
}
src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
+package com.keenwrite.processors.markdown;
+
+import com.vladsch.flexmark.html.HtmlRenderer;
+import com.vladsch.flexmark.parser.Parser;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.testfx.framework.junit5.ApplicationExtension;
+
+import java.nio.file.Path;
+import java.util.List;
+
+@ExtendWith(ApplicationExtension.class)
+public class ImageLinkExtensionTest {
+
+ /**
+ * Test that
+ * {@code ![Tooltip](images/filename.svg 'Title')}
+ * will produce
+ * {@code <img src="images/filename.svg" alt="Tooltip" title="Title" />}
+ */
+ @Test
+ void test_LocalImage_RelativePathWithExtension_ResolvedSuccessfully() {
+ final var path = Path.of( "." );
+ final var extension = ImageLinkExtension.create( path );
+ final var extensions = List.of( extension);
+ final var parser = Parser.builder().extensions( extensions ).build();
+ final var renderer = HtmlRenderer.builder().extensions( extensions ).build();
+ final var node = parser.parse( "![Tooltip](images/filename.svg 'Title')");
+ final var html = renderer.render(node);
+
+ System.out.println( html );
+ }
+}
Delta2161 lines added, 2016 lines removed, 145-line increase