Dave Jarvis' Repositories

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

Hook into scrolling via scrollbar thumb

AuthorDaveJarvis <email>
Date2020-06-22 18:39:52 GMT-0700
Commita62a9ebc682b3efa1560f68e07ac7f646331f6e0
Parent2c933a4
Delta2285 lines added, 2282 lines removed, 3-line increase
src/main/resources/com/scrivenvar/editor/markdown.css
/*
- * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
+ * Copyright 2020 Karl Tauber and White Magic Software, Ltd.
+ *
* All rights reserved.
*
.markdown-editor {
-fx-font-size: 14px;
-}
-
-/*---- headers ----*/
-
-.markdown-editor .h1 { -fx-font-size: 2.25em; }
-.markdown-editor .h2 { -fx-font-size: 1.75em; }
-.markdown-editor .h3 { -fx-font-size: 1.5em; }
-.markdown-editor .h4 { -fx-font-size: 1.25em; }
-.markdown-editor .h5 { -fx-font-size: 1.1em; }
-.markdown-editor .h6 { -fx-font-size: 1em; }
-
-.markdown-editor .h1,
-.markdown-editor .h2,
-.markdown-editor .h3,
-.markdown-editor .h4,
-.markdown-editor .h5,
-.markdown-editor .h6 {
- -fx-font-weight: bold;
- -fx-fill: derive(crimson, -20%);
-}
-
-
-/*---- inlines ----*/
-
-.markdown-editor .strong {
- -fx-font-weight: bold;
-}
-
-.markdown-editor .em {
- -fx-font-style: italic;
-}
-
-.markdown-editor .del {
- -fx-strikethrough: true;
-}
-
-.markdown-editor .a {
- -fx-fill: #4183C4 !important;
-}
-
-.markdown-editor .img {
- -fx-fill: #4183C4 !important;
-}
-
-.markdown-editor .code {
- -fx-font-family: monospace;
- -fx-fill: #090 !important;
-}
-
-
-/*---- blocks ----*/
-
-.markdown-editor .pre {
- -fx-font-family: monospace;
- -fx-fill: #060 !important;
-}
-
-.markdown-editor .blockquote {
- -fx-fill: #777;
-}
-
-
-/*---- lists ----*/
-
-.markdown-editor .ul {
-}
-
-.markdown-editor .ol {
-}
-
-.markdown-editor .li {
- -fx-fill: #444;
-}
-
-.markdown-editor .dl {
-}
-
-.markdown-editor .dt {
- -fx-font-weight: bold;
- -fx-font-style: italic;
-}
-
-.markdown-editor .dd {
- -fx-fill: #444;
-}
-
-
-/*---- table ----*/
-
-.markdown-editor .table {
- -fx-font-family: monospace;
-}
-
-.markdown-editor .thead {
-}
-
-.markdown-editor .tbody {
-}
-
-.markdown-editor .caption {
-}
-
-.markdown-editor .th {
- -fx-font-weight: bold;
-}
-
-.markdown-editor .tr {
}
-.markdown-editor .td {
+/* Subtly highlight the current paragraph. */
+.markdown-editor .paragraph-box:has-caret {
+ -fx-background-color: #fcfeff;
}
-
-
-/*---- misc ----*/
-.markdown-editor .html {
- -fx-font-family: monospace;
- -fx-fill: derive(crimson, -50%);
-}
-.markdown-editor .monospace {
- -fx-font-family: monospace;
+/* Light colour for selection highlight. */
+.markdown-editor .selection {
+ -fx-fill: #a6d2ff;
}
src/main/java/com/scrivenvar/FileEditorTab.java
private final Notifier mNotifier = Services.load( Notifier.class );
- private final EditorPane mEditorPane = new MarkdownEditorPane();
-
- private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
- private final BooleanProperty canUndo = new SimpleBooleanProperty();
- private final BooleanProperty canRedo = new SimpleBooleanProperty();
-
- /**
- * Character encoding used by the file (or default encoding if none found).
- */
- private Charset mEncoding = UTF_8;
-
- /**
- * File to load into the editor.
- */
- private Path mPath;
-
- public FileEditorTab( final Path path ) {
- setPath( path );
-
- mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
-
- setOnSelectionChanged( e -> {
- if( isSelected() ) {
- Platform.runLater( this::activated );
- }
- } );
- }
-
- private void updateTab() {
- setText( getTabTitle() );
- setGraphic( getModifiedMark() );
- setTooltip( getTabTooltip() );
- }
-
- /**
- * Returns the base filename (without the directory names).
- *
- * @return The untitled text if the path hasn't been set.
- */
- private String getTabTitle() {
- return getPath().getFileName().toString();
- }
-
- /**
- * Returns the full filename represented by the path.
- *
- * @return The untitled text if the path hasn't been set.
- */
- private Tooltip getTabTooltip() {
- final Path filePath = getPath();
- return new Tooltip( filePath == null ? "" : filePath.toString() );
- }
-
- /**
- * Returns a marker to indicate whether the file has been modified.
- *
- * @return "*" when the file has changed; otherwise null.
- */
- private Text getModifiedMark() {
- return isModified() ? new Text( "*" ) : null;
- }
-
- /**
- * Called when the user switches tab.
- */
- private void activated() {
- // Tab is closed or no longer active.
- if( getTabPane() == null || !isSelected() ) {
- return;
- }
-
- // Switch to the tab without loading if the contents are already in memory.
- if( getContent() != null ) {
- getEditorPane().requestFocus();
- return;
- }
-
- // Load the text and update the preview before the undo manager.
- load();
-
- // Track undo requests -- can only be called *after* load.
- initUndoManager();
- initLayout();
- initFocus();
- }
-
- private void initLayout() {
- setContent( getScrollPane() );
- }
-
- private Node getScrollPane() {
- return getEditorPane().getScrollPane();
- }
-
- private void initFocus() {
- getEditorPane().requestFocus();
- }
-
- private void initUndoManager() {
- final UndoManager<?> undoManager = getUndoManager();
- undoManager.forgetHistory();
-
- // Bind the editor undo manager to the properties.
- mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
- canUndo.bind( undoManager.undoAvailableProperty() );
- canRedo.bind( undoManager.redoAvailableProperty() );
- }
-
- /**
- * Searches from the caret position forward for the given string.
- *
- * @param needle The text string to match.
- */
- public void searchNext( final String needle ) {
- final String haystack = getEditorText();
- int index = haystack.indexOf( needle, getCaretPosition() );
-
- // Wrap around.
- if( index == -1 ) {
- index = haystack.indexOf( needle );
- }
-
- if( index >= 0 ) {
- setCaretPosition( index );
- getEditor().selectRange( index, index + needle.length() );
- }
- }
-
- /**
- * Returns the index into the text where the caret blinks happily away.
- *
- * @return A number from 0 to the editor's document text length.
- */
- public int getCaretPosition() {
- return getEditor().getCaretPosition();
- }
-
- /**
- * Moves the caret to a given offset.
- *
- * @param offset The new caret offset.
- */
- private void setCaretPosition( final int offset ) {
- getEditor().moveTo( offset );
- getEditor().requestFollowCaret();
- }
-
- /**
- * Returns the text area associated with this tab.
- *
- * @return A text editor.
- */
- private StyleClassedTextArea getEditor() {
- return getEditorPane().getEditor();
- }
-
- /**
- * Returns true if the given path exactly matches this tab's path.
- *
- * @param check The path to compare against.
- * @return true The paths are the same.
- */
- public boolean isPath( final Path check ) {
- final Path filePath = getPath();
-
- return filePath != null && filePath.equals( check );
- }
-
- /**
- * Reads the entire file contents from the path associated with this tab.
- */
- private void load() {
- final Path path = getPath();
- final File file = path.toFile();
-
- try {
- if( file.exists() ) {
- if( file.canWrite() && file.canRead() ) {
- final EditorPane pane = getEditorPane();
- pane.setText( asString( Files.readAllBytes( path ) ) );
- pane.scrollToTop();
- }
- else {
- final String msg = get(
- "FileEditor.loadFailed.message",
- file.toString(),
- get( "FileEditor.loadFailed.reason.permissions" )
- );
- getNotifier().notify( msg );
- }
- }
- } catch( final Exception ex ) {
- getNotifier().notify( ex );
- }
- }
-
- /**
- * Saves the entire file contents from the path associated with this tab.
- *
- * @return true The file has been saved.
- */
- public boolean save() {
- try {
- final EditorPane editor = getEditorPane();
- Files.write( getPath(), asBytes( editor.getText() ) );
- editor.getUndoManager().mark();
- return true;
- } catch( final Exception ex ) {
- return alert(
- "FileEditor.saveFailed.title",
- "FileEditor.saveFailed.message",
- ex
- );
- }
- }
-
- /**
- * Creates an alert dialog and waits for it to close.
- *
- * @param titleKey Resource bundle key for the alert dialog title.
- * @param messageKey Resource bundle key for the alert dialog message.
- * @param e The unexpected happening.
- * @return false
- */
- @SuppressWarnings("SameParameterValue")
- private boolean alert(
- final String titleKey, final String messageKey, final Exception e ) {
- final Notifier service = getNotifier();
- final Path filePath = getPath();
-
- final Notification message = service.createNotification(
- get( titleKey ),
- get( messageKey ),
- filePath == null ? "" : filePath,
- e.getMessage()
- );
-
- try {
- service.createError( getWindow(), message ).showAndWait();
- } catch( final Exception ex ) {
- getNotifier().notify( ex );
- }
-
- return false;
- }
-
- private Window getWindow() {
- final Scene scene = getEditorPane().getScene();
-
- if( scene == null ) {
- throw new UnsupportedOperationException( "No scene window available" );
- }
-
- return scene.getWindow();
- }
-
- /**
- * Returns a best guess at the file encoding. If the encoding could not be
- * detected, this will return the default charset for the JVM.
- *
- * @param bytes The bytes to perform character encoding detection.
- * @return The character encoding.
- */
- private Charset detectEncoding( final byte[] bytes ) {
- final var detector = new UniversalDetector( null );
- detector.handleData( bytes, 0, bytes.length );
- detector.dataEnd();
-
- final String charset = detector.getDetectedCharset();
-
- return charset == null
- ? Charset.defaultCharset()
- : Charset.forName( charset.toUpperCase( ENGLISH ) );
- }
-
- /**
- * Converts the given string to an array of bytes using the encoding that was
- * originally detected (if any) and associated with this file.
- *
- * @param text The text to convert into the original file encoding.
- * @return A series of bytes ready for writing to a file.
- */
- private byte[] asBytes( final String text ) {
- return text.getBytes( getEncoding() );
- }
-
- /**
- * Converts the given bytes into a Java String. This will call setEncoding
- * with the encoding detected by the CharsetDetector.
- *
- * @param text The text of unknown character encoding.
- * @return The text, in its auto-detected encoding, as a String.
- */
- private String asString( final byte[] text ) {
- setEncoding( detectEncoding( text ) );
- return new String( text, getEncoding() );
- }
-
- /**
- * Returns the path to the file being edited in this tab.
- *
- * @return A non-null instance.
- */
- public Path getPath() {
- return mPath;
- }
-
- /**
- * Sets the path to a file for editing and then updates the tab with the
- * file contents.
- *
- * @param path A non-null instance.
- */
- public void setPath( final Path path ) {
- assert path != null;
- mPath = path;
-
- updateTab();
- }
-
- public boolean isModified() {
- return mModified.get();
- }
-
- ReadOnlyBooleanProperty modifiedProperty() {
- return mModified.getReadOnlyProperty();
- }
-
- BooleanProperty canUndoProperty() {
- return this.canUndo;
- }
-
- BooleanProperty canRedoProperty() {
- return this.canRedo;
- }
-
- private UndoManager<?> getUndoManager() {
- return getEditorPane().getUndoManager();
- }
-
- /**
- * Forwards to the editor pane's listeners for text change events.
- *
- * @param listener The listener to notify when the text changes.
- */
- public void addTextChangeListener( final ChangeListener<String> listener ) {
- getEditorPane().addTextChangeListener( listener );
- }
-
- /**
- * Forwards to the editor pane's listeners for caret paragraph change events.
- *
- * @param listener The listener to notify when the caret changes paragraphs.
- */
- public void addCaretParagraphListener(
- final ChangeListener<Integer> listener ) {
- getEditorPane().addCaretParagraphListener( listener );
- }
-
- public <T extends Event> void addEventFilter(
- final EventType<T> eventType,
- final EventHandler<? super T> eventFilter ) {
- getEditorPane().getEditor().addEventFilter( eventType, eventFilter );
- }
-
- /**
- * Forwards the request to the editor pane.
- *
- * @return The text to process.
- */
- public String getEditorText() {
- return getEditorPane().getText();
- }
-
- /**
- * Returns the editor pane, or creates one if it doesn't yet exist.
- *
- * @return The editor pane, never null.
- */
- @NotNull
- public EditorPane getEditorPane() {
+ private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
+
+ private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
+ private final BooleanProperty canUndo = new SimpleBooleanProperty();
+ private final BooleanProperty canRedo = new SimpleBooleanProperty();
+
+ /**
+ * Character encoding used by the file (or default encoding if none found).
+ */
+ private Charset mEncoding = UTF_8;
+
+ /**
+ * File to load into the editor.
+ */
+ private Path mPath;
+
+ public FileEditorTab( final Path path ) {
+ setPath( path );
+
+ mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
+
+ setOnSelectionChanged( e -> {
+ if( isSelected() ) {
+ Platform.runLater( this::activated );
+ }
+ } );
+ }
+
+ private void updateTab() {
+ setText( getTabTitle() );
+ setGraphic( getModifiedMark() );
+ setTooltip( getTabTooltip() );
+ }
+
+ /**
+ * Returns the base filename (without the directory names).
+ *
+ * @return The untitled text if the path hasn't been set.
+ */
+ private String getTabTitle() {
+ return getPath().getFileName().toString();
+ }
+
+ /**
+ * Returns the full filename represented by the path.
+ *
+ * @return The untitled text if the path hasn't been set.
+ */
+ private Tooltip getTabTooltip() {
+ final Path filePath = getPath();
+ return new Tooltip( filePath == null ? "" : filePath.toString() );
+ }
+
+ /**
+ * Returns a marker to indicate whether the file has been modified.
+ *
+ * @return "*" when the file has changed; otherwise null.
+ */
+ private Text getModifiedMark() {
+ return isModified() ? new Text( "*" ) : null;
+ }
+
+ /**
+ * Called when the user switches tab.
+ */
+ private void activated() {
+ // Tab is closed or no longer active.
+ if( getTabPane() == null || !isSelected() ) {
+ return;
+ }
+
+ // Switch to the tab without loading if the contents are already in memory.
+ if( getContent() != null ) {
+ getEditorPane().requestFocus();
+ return;
+ }
+
+ // Load the text and update the preview before the undo manager.
+ load();
+
+ // Track undo requests -- can only be called *after* load.
+ initUndoManager();
+ initLayout();
+ initFocus();
+ }
+
+ private void initLayout() {
+ setContent( getScrollPane() );
+ }
+
+ private Node getScrollPane() {
+ return getEditorPane().getScrollPane();
+ }
+
+ private void initFocus() {
+ getEditorPane().requestFocus();
+ }
+
+ private void initUndoManager() {
+ final UndoManager<?> undoManager = getUndoManager();
+ undoManager.forgetHistory();
+
+ // Bind the editor undo manager to the properties.
+ mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
+ canUndo.bind( undoManager.undoAvailableProperty() );
+ canRedo.bind( undoManager.redoAvailableProperty() );
+ }
+
+ /**
+ * Searches from the caret position forward for the given string.
+ *
+ * @param needle The text string to match.
+ */
+ public void searchNext( final String needle ) {
+ final String haystack = getEditorText();
+ int index = haystack.indexOf( needle, getCaretPosition() );
+
+ // Wrap around.
+ if( index == -1 ) {
+ index = haystack.indexOf( needle );
+ }
+
+ if( index >= 0 ) {
+ setCaretPosition( index );
+ getEditor().selectRange( index, index + needle.length() );
+ }
+ }
+
+ /**
+ * Returns the index into the text where the caret blinks happily away.
+ *
+ * @return A number from 0 to the editor's document text length.
+ */
+ public int getCaretPosition() {
+ return getEditor().getCaretPosition();
+ }
+
+ /**
+ * Moves the caret to a given offset.
+ *
+ * @param offset The new caret offset.
+ */
+ private void setCaretPosition( final int offset ) {
+ getEditor().moveTo( offset );
+ getEditor().requestFollowCaret();
+ }
+
+ /**
+ * Returns the text area associated with this tab.
+ *
+ * @return A text editor.
+ */
+ private StyleClassedTextArea getEditor() {
+ return getEditorPane().getEditor();
+ }
+
+ /**
+ * Returns true if the given path exactly matches this tab's path.
+ *
+ * @param check The path to compare against.
+ * @return true The paths are the same.
+ */
+ public boolean isPath( final Path check ) {
+ final Path filePath = getPath();
+
+ return filePath != null && filePath.equals( check );
+ }
+
+ /**
+ * Reads the entire file contents from the path associated with this tab.
+ */
+ private void load() {
+ final Path path = getPath();
+ final File file = path.toFile();
+
+ try {
+ if( file.exists() ) {
+ if( file.canWrite() && file.canRead() ) {
+ final EditorPane pane = getEditorPane();
+ pane.setText( asString( Files.readAllBytes( path ) ) );
+ pane.scrollToTop();
+ }
+ else {
+ final String msg = get(
+ "FileEditor.loadFailed.message",
+ file.toString(),
+ get( "FileEditor.loadFailed.reason.permissions" )
+ );
+ getNotifier().notify( msg );
+ }
+ }
+ } catch( final Exception ex ) {
+ getNotifier().notify( ex );
+ }
+ }
+
+ /**
+ * Saves the entire file contents from the path associated with this tab.
+ *
+ * @return true The file has been saved.
+ */
+ public boolean save() {
+ try {
+ final EditorPane editor = getEditorPane();
+ Files.write( getPath(), asBytes( editor.getText() ) );
+ editor.getUndoManager().mark();
+ return true;
+ } catch( final Exception ex ) {
+ return alert(
+ "FileEditor.saveFailed.title",
+ "FileEditor.saveFailed.message",
+ ex
+ );
+ }
+ }
+
+ /**
+ * Creates an alert dialog and waits for it to close.
+ *
+ * @param titleKey Resource bundle key for the alert dialog title.
+ * @param messageKey Resource bundle key for the alert dialog message.
+ * @param e The unexpected happening.
+ * @return false
+ */
+ @SuppressWarnings("SameParameterValue")
+ private boolean alert(
+ final String titleKey, final String messageKey, final Exception e ) {
+ final Notifier service = getNotifier();
+ final Path filePath = getPath();
+
+ final Notification message = service.createNotification(
+ get( titleKey ),
+ get( messageKey ),
+ filePath == null ? "" : filePath,
+ e.getMessage()
+ );
+
+ try {
+ service.createError( getWindow(), message ).showAndWait();
+ } catch( final Exception ex ) {
+ getNotifier().notify( ex );
+ }
+
+ return false;
+ }
+
+ private Window getWindow() {
+ final Scene scene = getEditorPane().getScene();
+
+ if( scene == null ) {
+ throw new UnsupportedOperationException( "No scene window available" );
+ }
+
+ return scene.getWindow();
+ }
+
+ /**
+ * Returns a best guess at the file encoding. If the encoding could not be
+ * detected, this will return the default charset for the JVM.
+ *
+ * @param bytes The bytes to perform character encoding detection.
+ * @return The character encoding.
+ */
+ private Charset detectEncoding( final byte[] bytes ) {
+ final var detector = new UniversalDetector( null );
+ detector.handleData( bytes, 0, bytes.length );
+ detector.dataEnd();
+
+ final String charset = detector.getDetectedCharset();
+
+ return charset == null
+ ? Charset.defaultCharset()
+ : Charset.forName( charset.toUpperCase( ENGLISH ) );
+ }
+
+ /**
+ * Converts the given string to an array of bytes using the encoding that was
+ * originally detected (if any) and associated with this file.
+ *
+ * @param text The text to convert into the original file encoding.
+ * @return A series of bytes ready for writing to a file.
+ */
+ private byte[] asBytes( final String text ) {
+ return text.getBytes( getEncoding() );
+ }
+
+ /**
+ * Converts the given bytes into a Java String. This will call setEncoding
+ * with the encoding detected by the CharsetDetector.
+ *
+ * @param text The text of unknown character encoding.
+ * @return The text, in its auto-detected encoding, as a String.
+ */
+ private String asString( final byte[] text ) {
+ setEncoding( detectEncoding( text ) );
+ return new String( text, getEncoding() );
+ }
+
+ /**
+ * Returns the path to the file being edited in this tab.
+ *
+ * @return A non-null instance.
+ */
+ public Path getPath() {
+ return mPath;
+ }
+
+ /**
+ * Sets the path to a file for editing and then updates the tab with the
+ * file contents.
+ *
+ * @param path A non-null instance.
+ */
+ public void setPath( final Path path ) {
+ assert path != null;
+ mPath = path;
+
+ updateTab();
+ }
+
+ public boolean isModified() {
+ return mModified.get();
+ }
+
+ ReadOnlyBooleanProperty modifiedProperty() {
+ return mModified.getReadOnlyProperty();
+ }
+
+ BooleanProperty canUndoProperty() {
+ return this.canUndo;
+ }
+
+ BooleanProperty canRedoProperty() {
+ return this.canRedo;
+ }
+
+ private UndoManager<?> getUndoManager() {
+ return getEditorPane().getUndoManager();
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for text change events.
+ *
+ * @param listener The listener to notify when the text changes.
+ */
+ public void addTextChangeListener( final ChangeListener<String> listener ) {
+ getEditorPane().addTextChangeListener( listener );
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for caret change events.
+ *
+ * @param listener Notified when the caret position changes.
+ */
+ public void addCaretPositionListener(
+ final ChangeListener<? super Integer> listener ) {
+ getEditorPane().addCaretPositionListener( listener );
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for paragraph index change events.
+ *
+ * @param listener Notified when the caret's paragraph index changes.
+ */
+ public void addCaretParagraphListener(
+ final ChangeListener<? super Integer> listener ) {
+ getEditorPane().addCaretParagraphListener( listener );
+ }
+
+ public <T extends Event> void addEventFilter(
+ final EventType<T> eventType,
+ final EventHandler<? super T> eventFilter ) {
+ getEditorPane().getEditor().addEventFilter( eventType, eventFilter );
+ }
+
+ /**
+ * Forwards the request to the editor pane.
+ *
+ * @return The text to process.
+ */
+ public String getEditorText() {
+ return getEditorPane().getText();
+ }
+
+ /**
+ * Returns the editor pane, or creates one if it doesn't yet exist.
+ *
+ * @return The editor pane, never null.
+ */
+ @NotNull
+ public MarkdownEditorPane getEditorPane() {
return mEditorPane;
}
src/main/java/com/scrivenvar/FileEditorTabPane.java
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableValue;
-import javafx.collections.ListChangeListener;
-import javafx.collections.ObservableList;
-import javafx.event.Event;
-import javafx.scene.Node;
-import javafx.scene.control.Alert;
-import javafx.scene.control.ButtonType;
-import javafx.scene.control.Tab;
-import javafx.scene.control.TabPane;
-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.function.Consumer;
-import java.util.prefs.Preferences;
-import java.util.stream.Collectors;
-
-import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
-import static com.scrivenvar.FileType.*;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.service.events.Notifier.YES;
-
-/**
- * Tab pane for file editors.
- *
- * @author Karl Tauber and White Magic Software, Ltd.
- */
-public final class FileEditorTabPane extends TabPane {
-
- private final static String FILTER_EXTENSION_TITLES =
- "Dialog.file.choose.filter";
-
- private final static Options sOptions = Services.load( Options.class );
- private final static Settings sSettings = Services.load( Settings.class );
- private final static Notifier sNotifier = Services.load( Notifier.class );
-
- private final ReadOnlyObjectWrapper<Path> openDefinition =
- new ReadOnlyObjectWrapper<>();
- private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
- new ReadOnlyObjectWrapper<>();
- private final ReadOnlyBooleanWrapper anyFileEditorModified =
- new ReadOnlyBooleanWrapper();
- private final Consumer<Double> mScrollEventObserver;
- private final ChangeListener<Integer> mCaretListener;
-
- /**
- * Constructs a new file editor tab pane.
- */
- public FileEditorTabPane(
- final Consumer<Double> scrollEventObserver,
- final ChangeListener<Integer> caretListener ) {
- final ObservableList<Tab> tabs = getTabs();
-
- setFocusTraversable( false );
- setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
-
- addTabSelectionListener(
- ( ObservableValue<? extends Tab> tabPane,
- final Tab oldTab, final Tab newTab ) -> {
-
- if( newTab != null ) {
- mActiveFileEditor.set( (FileEditorTab) newTab );
- }
- }
- );
-
- final ChangeListener<Boolean> modifiedListener =
- ( observable, oldValue, newValue ) -> {
- for( final Tab tab : tabs ) {
- if( ((FileEditorTab) tab).isModified() ) {
- this.anyFileEditorModified.set( true );
- break;
- }
- }
- };
-
- tabs.addListener(
- (ListChangeListener<Tab>) change -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- change.getAddedSubList().forEach(
- ( tab ) ->
- ((FileEditorTab) tab).modifiedProperty()
- .addListener( modifiedListener ) );
- }
- else if( change.wasRemoved() ) {
- change.getRemoved().forEach(
- ( tab ) ->
- ((FileEditorTab) tab).modifiedProperty()
- .removeListener( modifiedListener ) );
- }
- }
-
- // Changes in the tabs may also change anyFileEditorModified property
- // (e.g. closed modified file)
- modifiedListener.changed( null, null, null );
- }
- );
-
- mScrollEventObserver = scrollEventObserver;
- mCaretListener = caretListener;
- }
-
- /**
- * 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 this.anyFileEditorModified.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.getEditorPane().getScrollPane().estimatedScrollYProperty().addObserver(
- mScrollEventObserver
- );
-
- 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.addCaretParagraphListener( mCaretListener );
-
- 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 when the user selects New from the File menu.
- */
- void newEditor() {
- final Path defaultPath = getDefaultPath();
- final FileEditorTab tab = createFileEditor( defaultPath );
-
- getTabs().add( tab );
- getSelectionModel().select( tab );
- }
-
- void openFileDialog() {
- final String title = get( "Dialog.file.choose.open.title" );
- final FileChooser dialog = createFileChooser( 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 FileTypePredicate predicate =
- new FileTypePredicate( extensions );
-
- // The user might have opened multiple definitions files. These will
- // be discarded from the text editable files.
- final List<File> definitions
- = files.stream().filter( predicate ).collect( Collectors.toList() );
-
- // Create a modifiable list to remove any definition files that were
- // opened.
- final List<File> 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 this.openDefinition;
- }
-
- /**
- * 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() );
- }
-
- /**
- * Called when the contents of the editor are to be saved.
- *
- * @param tab The tab containing content to save.
- * @return true The contents were saved (or needn't be saved).
- */
- public boolean saveEditor( final FileEditorTab tab ) {
- if( tab == null || !tab.isModified() ) {
- return true;
- }
-
- return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
- }
-
- /**
- * 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 FileChooser fileChooser = createFileChooser( get(
- "Dialog.file.choose.save.title" ) );
- final File file = fileChooser.showSaveDialog( getWindow() );
- if( file == null ) {
- return false;
- }
-
- saveLastDirectory( file );
- tab.setPath( file.toPath() );
-
- return tab.save();
- }
-
- void saveAllEditors() {
- for( final FileEditorTab fileEditor : getAllEditors() ) {
- saveEditor( fileEditor );
- }
- }
-
- /**
- * 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 ? saveEditor( tab ) : 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 ];
-
- for( int i = 0; i < length; i++ ) {
- allEditors[ i ] = (FileEditorTab) tabs.get( i );
- }
-
- return allEditors;
- }
-
- /**
- * Returns the file editor tab that has the given path.
- *
- * @return null No file editor tab for the given path was found.
- */
- private FileEditorTab findEditor( final Path path ) {
- for( final Tab tab : getTabs() ) {
- final FileEditorTab fileEditor = (FileEditorTab) tab;
-
- if( fileEditor.isPath( path ) ) {
- return fileEditor;
- }
- }
-
- return null;
- }
-
- private FileChooser createFileChooser( String title ) {
- final FileChooser fileChooser = new FileChooser();
-
- fileChooser.setTitle( title );
- fileChooser.getExtensionFilters().addAll(
- createExtensionFilters() );
-
- final String lastDirectory = getPreferences().get( "lastDirectory", null );
- File file = new File( (lastDirectory != null) ? lastDirectory : "." );
-
- if( !file.isDirectory() ) {
- file = new File( "." );
- }
-
- fileChooser.setInitialDirectory( file );
- return fileChooser;
- }
-
- private List<ExtensionFilter> createExtensionFilters() {
- final List<ExtensionFilter> list = new ArrayList<>();
-
- // TODO: Return a list of all properties that match the filter prefix.
- // This will allow dynamic filters to be added and removed just by
- // updating the properties file.
- list.add( createExtensionFilter( ALL ) );
- list.add( createExtensionFilter( SOURCE ) );
- list.add( createExtensionFilter( DEFINITION ) );
- list.add( createExtensionFilter( XML ) );
- return list;
- }
-
- /**
- * Returns a filter for file name extensions recognized by the application
- * that can be opened by the user.
- *
- * @param filetype Used to find the globbing pattern for extensions.
- * @return A filename filter suitable for use by a FileDialog instance.
- */
- private ExtensionFilter createExtensionFilter( final FileType filetype ) {
- final String tKey = String.format( "%s.title.%s",
- FILTER_EXTENSION_TITLES,
- filetype );
- final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
-
- return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
- }
-
- private List<String> getExtensions( final String key ) {
- return getSettings().getStringSettingList( key );
- }
-
- private void saveLastDirectory( final File file ) {
- getPreferences().put( "lastDirectory", file.getParent() );
- }
-
- public void restorePreferences() {
- int activeIndex = 0;
-
- final Preferences preferences = getPreferences();
- final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
- final String activeFileName = preferences.get( "activeFile", null );
-
- final List<File> files = new ArrayList<>( fileNames.length );
-
- for( final String fileName : fileNames ) {
- final File file = new File( fileName );
-
- if( file.exists() ) {
- files.add( file );
-
- if( fileName.equals( activeFileName ) ) {
- activeIndex = files.size() - 1;
- }
- }
- }
-
- if( files.isEmpty() ) {
- newEditor();
- }
- else {
- openEditors( files, activeIndex );
- }
- }
-
- public void persistPreferences() {
- final ObservableList<Tab> allEditors = getTabs();
- final List<String> fileNames = new ArrayList<>( allEditors.size() );
-
- for( final Tab tab : allEditors ) {
- final FileEditorTab fileEditor = (FileEditorTab) tab;
- final Path filePath = fileEditor.getPath();
-
- if( filePath != null ) {
- fileNames.add( filePath.toString() );
- }
- }
-
- final Preferences preferences = getPreferences();
- Utils.putPrefsStrings( preferences,
- "file",
- fileNames.toArray( new String[ 0 ] ) );
-
- final FileEditorTab activeEditor = getActiveFileEditor();
- final Path filePath = activeEditor == null ? null : activeEditor.getPath();
-
- if( filePath == null ) {
- preferences.remove( "activeFile" );
- }
- else {
- preferences.put( "activeFile", filePath.toString() );
- }
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.event.Event;
+import javafx.scene.Node;
+import javafx.scene.control.Alert;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+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.scrivenvar.Constants.GLOB_PREFIX_FILE;
+import static com.scrivenvar.FileType.*;
+import static com.scrivenvar.Messages.get;
+import static com.scrivenvar.service.events.Notifier.YES;
+
+/**
+ * Tab pane for file editors.
+ *
+ * @author Karl Tauber and White Magic Software, Ltd.
+ */
+public final class FileEditorTabPane extends TabPane {
+
+ private final static String FILTER_EXTENSION_TITLES =
+ "Dialog.file.choose.filter";
+
+ private final static Options sOptions = Services.load( Options.class );
+ private final static Settings sSettings = Services.load( Settings.class );
+ private final static 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;
+ private final ChangeListener<Integer> mCaretParagraphListener;
+
+ /**
+ * Constructs a new file editor tab pane.
+ *
+ * @param caretPositionListener Listens for changes to caret position so
+ * that the status bar can update.
+ * @param caretParagraphListener Listens for changes to the caret's paragraph
+ * so that scrolling may occur.
+ */
+ public FileEditorTabPane(
+ final ChangeListener<Integer> caretPositionListener,
+ final ChangeListener<Integer> caretParagraphListener ) {
+ final ObservableList<Tab> 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 ) ->
+ ((FileEditorTab) tab).modifiedProperty()
+ .removeListener( modifiedListener ) );
+ }
+ }
+
+ // Changes in the tabs may also change anyFileEditorModified property
+ // (e.g. closed modified file)
+ modifiedListener.changed( null, null, null );
+ }
+ );
+
+ mCaretPositionListener = caretPositionListener;
+ mCaretParagraphListener = caretParagraphListener;
+ }
+
+ /**
+ * 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 );
+ tab.addCaretParagraphListener( mCaretParagraphListener );
+
+ 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 when the user selects New from the File menu.
+ */
+ void newEditor() {
+ final FileEditorTab tab = createFileEditor( getDefaultPath() );
+
+ getTabs().add( tab );
+ getSelectionModel().select( tab );
+ }
+
+ void openFileDialog() {
+ final String title = get( "Dialog.file.choose.open.title" );
+ final FileChooser dialog = createFileChooser( 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 FileTypePredicate predicate =
+ new FileTypePredicate( extensions );
+
+ // The user might have opened multiple definitions files. These will
+ // be discarded from the text editable files.
+ final List<File> definitions
+ = files.stream().filter( predicate ).collect( Collectors.toList() );
+
+ // Create a modifiable list to remove any definition files that were
+ // opened.
+ final List<File> 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() );
+ }
+
+ /**
+ * Called when the contents of the editor are to be saved.
+ *
+ * @param tab The tab containing content to save.
+ * @return true The contents were saved (or needn't be saved).
+ */
+ public boolean saveEditor( final FileEditorTab tab ) {
+ if( tab == null || !tab.isModified() ) {
+ return true;
+ }
+
+ return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
+ }
+
+ /**
+ * 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 FileChooser fileChooser = createFileChooser( get(
+ "Dialog.file.choose.save.title" ) );
+ final File file = fileChooser.showSaveDialog( getWindow() );
+ if( file == null ) {
+ return false;
+ }
+
+ saveLastDirectory( file );
+ tab.setPath( file.toPath() );
+
+ return tab.save();
+ }
+
+ void saveAllEditors() {
+ for( final FileEditorTab fileEditor : getAllEditors() ) {
+ saveEditor( fileEditor );
+ }
+ }
+
+ /**
+ * 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 ? saveEditor( tab ) : 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 ];
+
+ for( int i = 0; i < length; i++ ) {
+ allEditors[ i ] = (FileEditorTab) tabs.get( i );
+ }
+
+ return allEditors;
+ }
+
+ /**
+ * Returns the file editor tab that has the given path.
+ *
+ * @return null No file editor tab for the given path was found.
+ */
+ private FileEditorTab findEditor( final Path path ) {
+ for( final Tab tab : getTabs() ) {
+ final FileEditorTab fileEditor = (FileEditorTab) tab;
+
+ if( fileEditor.isPath( path ) ) {
+ return fileEditor;
+ }
+ }
+
+ return null;
+ }
+
+ private FileChooser createFileChooser( String title ) {
+ final FileChooser fileChooser = new FileChooser();
+
+ fileChooser.setTitle( title );
+ fileChooser.getExtensionFilters().addAll(
+ createExtensionFilters() );
+
+ final String lastDirectory = getPreferences().get( "lastDirectory", null );
+ File file = new File( (lastDirectory != null) ? lastDirectory : "." );
+
+ if( !file.isDirectory() ) {
+ file = new File( "." );
+ }
+
+ fileChooser.setInitialDirectory( file );
+ return fileChooser;
+ }
+
+ private List<ExtensionFilter> createExtensionFilters() {
+ final List<ExtensionFilter> list = new ArrayList<>();
+
+ // TODO: Return a list of all properties that match the filter prefix.
+ // This will allow dynamic filters to be added and removed just by
+ // updating the properties file.
+ list.add( createExtensionFilter( ALL ) );
+ list.add( createExtensionFilter( SOURCE ) );
+ list.add( createExtensionFilter( DEFINITION ) );
+ list.add( createExtensionFilter( XML ) );
+ return list;
+ }
+
+ /**
+ * Returns a filter for file name extensions recognized by the application
+ * that can be opened by the user.
+ *
+ * @param filetype Used to find the globbing pattern for extensions.
+ * @return A filename filter suitable for use by a FileDialog instance.
+ */
+ private ExtensionFilter createExtensionFilter( final FileType filetype ) {
+ final String tKey = String.format( "%s.title.%s",
+ FILTER_EXTENSION_TITLES,
+ filetype );
+ final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
+
+ return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
+ }
+
+ private void saveLastDirectory( final File file ) {
+ getPreferences().put( "lastDirectory", file.getParent() );
+ }
+
+ public void initPreferences() {
+ int activeIndex = 0;
+
+ final Preferences preferences = getPreferences();
+ final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
+ final String activeFileName = preferences.get( "activeFile", null );
+
+ final List<File> files = new ArrayList<>( fileNames.length );
+
+ for( final String fileName : fileNames ) {
+ final File file = new File( fileName );
+
+ if( file.exists() ) {
+ files.add( file );
+
+ if( fileName.equals( activeFileName ) ) {
+ activeIndex = files.size() - 1;
+ }
+ }
+ }
+
+ if( files.isEmpty() ) {
+ newEditor();
+ }
+ else {
+ openEditors( files, activeIndex );
+ }
+ }
+
+ public void persistPreferences() {
+ final ObservableList<Tab> allEditors = getTabs();
+ final List<String> fileNames = new ArrayList<>( allEditors.size() );
+
+ for( final Tab tab : allEditors ) {
+ final FileEditorTab fileEditor = (FileEditorTab) tab;
+ final Path filePath = fileEditor.getPath();
+
+ if( filePath != null ) {
+ fileNames.add( filePath.toString() );
+ }
+ }
+
+ final Preferences preferences = getPreferences();
+ Utils.putPrefsStrings( preferences,
+ "file",
+ fileNames.toArray( new String[ 0 ] ) );
+
+ final FileEditorTab activeEditor = getActiveFileEditor();
+ final Path filePath = activeEditor == null ? null : activeEditor.getPath();
+
+ if( filePath == null ) {
+ preferences.remove( "activeFile" );
+ }
+ else {
+ preferences.put( "activeFile", filePath.toString() );
+ }
+ }
+
+ private List<String> getExtensions( final String key ) {
+ return getSettings().getStringSettingList( key );
}
src/main/java/com/scrivenvar/Main.java
import com.scrivenvar.service.Options;
import com.scrivenvar.service.Snitch;
-import com.scrivenvar.service.events.Notifier;
import com.scrivenvar.util.StageState;
import javafx.application.Application;
LogManager.getLogManager().reset();
}
-
- private static Application sApplication;
private final Options mOptions = Services.load( Options.class );
- private final Notifier mNotifier = Services.load( Notifier.class );
private final Snitch mSnitch = Services.load( Snitch.class );
private final Thread mSnitchThread = new Thread( getSnitch() );
@Override
public void start( final Stage stage ) {
- initApplication();
- initNotifyService();
initState( stage );
initStage( stage );
FilePreferencesFactory.class.getName()
);
- }
-
- public static void showDocument( final String uri ) {
- getApplication().getHostServices().showDocument( uri );
- }
-
- private void initApplication() {
- sApplication = this;
- }
-
- /**
- * Constructs the notify service and appends the main window to the list of
- * notification observers.
- */
- private void initNotifyService() {
- mNotifier.addObserver( getMainWindow() );
}
}
- private synchronized Snitch getSnitch() {
+ private Snitch getSnitch() {
return mSnitch;
}
private Thread getSnitchThread() {
return mSnitchThread;
}
- private synchronized Options getOptions() {
+ private Options getOptions() {
return mOptions;
- }
-
- private Scene getScene() {
- return getMainWindow().getScene();
}
private MainWindow getMainWindow() {
return mMainWindow;
}
- private static Application getApplication() {
- return sApplication;
+ private Scene getScene() {
+ return getMainWindow().getScene();
}
src/main/java/com/scrivenvar/MainWindow.java
import javafx.scene.control.*;
import javafx.scene.control.Alert.AlertType;
-import javafx.scene.image.Image;
-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.Window;
-import javafx.stage.WindowEvent;
-import javafx.util.Duration;
-import org.controlsfx.control.StatusBar;
-import org.fxmisc.flowless.VirtualizedScrollPane;
-import org.fxmisc.richtext.StyleClassedTextArea;
-
-import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Observable;
-import java.util.Observer;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.prefs.Preferences;
-
-import static com.scrivenvar.Constants.*;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.util.StageState.*;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
-import static javafx.event.Event.fireEvent;
-import static javafx.scene.input.KeyCode.ENTER;
-import static javafx.scene.input.KeyCode.TAB;
-import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
-
-/**
- * Main window containing a tab pane in the center for file editors.
- *
- * @author Karl Tauber and White Magic Software, Ltd.
- */
-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 final static Options OPTIONS = Services.load( Options.class );
- private final static Snitch SNITCH = Services.load( Snitch.class );
- private final static Notifier NOTIFIER = Services.load( Notifier.class );
-
- private final Scene mScene;
- private final StatusBar mStatusBar;
- private final Text mLineNumberText;
- private final TextField mFindTextField;
-
- private final Object mMutex = new Object();
-
- /**
- * Prevents scroll events of {@link VirtualizedScrollPane} from jumping
- * about while the user is typing.
- */
- private long mLastTyped;
-
- /**
- * 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 );
-
- /**
- * Called when the definition data is changed.
- */
- private final EventHandler<TreeItem.TreeModificationEvent<Event>>
- mTreeHandler = event -> {
- exportDefinitions( getDefinitionPath() );
- interpolateResolvedMap();
- refreshActiveTab();
- };
-
- /**
- * Called to synchronize the scrolling areas. This will suppress any
- * scroll events that happen shortly after the user has typed a key.
- * See {@link Constants#KEYBOARD_SCROLL_DELAY} for details.
- */
- private final Consumer<Double> mScrollEventObserver = o -> {
- if( now() - mLastTyped > KEYBOARD_SCROLL_DELAY ) {
- final var pPreviewPane = getPreviewPane();
- final var pScrollPane = pPreviewPane.getScrollPane();
-
- final var eScrollPane = getActiveEditor().getScrollPane();
- final int eScrollY =
- eScrollPane.estimatedScrollYProperty().getValue().intValue();
- final int eHeight = (int)
- (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
- - eScrollPane.getHeight());
- final double eRatio = eHeight > 0
- ? Math.min( Math.max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
-
- final var pScrollBar = pPreviewPane.getVerticalScrollBar();
- final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
- final var pScrollY = (int) (pHeight * eRatio);
-
- // Reduce concurrent modification exceptions when setting the vertical
- // scroll bar position.
- synchronized( mMutex ) {
- Platform.runLater( () -> {
- pScrollBar.setValue( pScrollY );
- pScrollPane.repaint();
- } );
- }
- }
- };
-
- /**
- * 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 ) {
- getVariableNameInjector().injectSelectedItem();
- }
- };
-
- /**
- * Called to switch to the definition pane when the user presses TAB.
- */
- private final EventHandler<? super KeyEvent> mEditorKeyHandler =
- (EventHandler<KeyEvent>) event -> {
- if( event.getCode() == TAB ) {
- getDefinitionPane().requestFocus();
- event.consume();
- }
- else {
- mLastTyped = now();
-
- synchronized( mMutex ) {
- final var previewPane = getPreviewPane();
- final var scrollPane = previewPane.getScrollPane();
-
- Platform.runLater( () -> {
- final String id = getActiveEditor().getCurrentParagraphId();
- previewPane.scrollTo( id );
- scrollPane.repaint();
- } );
- }
- }
- };
-
- private final ChangeListener<Integer> mCaretListener = ( i, j, k ) -> {
- final FileEditorTab tab = getActiveFileEditor();
- final EditorPane pane = tab.getEditorPane();
- final StyleClassedTextArea editor = pane.getEditor();
-
- getLineNumberText().setText(
- get( STATUS_BAR_LINE,
- editor.getCurrentParagraph() + 1,
- editor.getParagraphs().size(),
- editor.getCaretPosition()
- )
- );
- };
-
- private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
- private final DefinitionPane mDefinitionPane = new DefinitionPane();
- private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
- private final FileEditorTabPane mFileEditorPane =
- new FileEditorTabPane( mScrollEventObserver, mCaretListener );
-
- /**
- * Listens on the definition pane for double-click events.
- */
- private final VariableNameInjector mVariableNameInjector
- = new VariableNameInjector( mDefinitionPane );
-
- public MainWindow() {
- mStatusBar = createStatusBar();
- mLineNumberText = createLineNumberText();
- mFindTextField = createFindTextField();
- mScene = createScene();
-
- initLayout();
- initFindInput();
- initSnitch();
- initDefinitionListener();
- initTabAddedListener();
- initTabChangedListener();
- restorePreferences();
- initVariableNameInjector();
- }
-
- private void initLayout() {
- final Scene appScene = getScene();
-
- appScene.getStylesheets().add( STYLESHEET_SCENE );
-
- // TODO: Apply an XML syntax highlighting for XML files.
-// appScene.getStylesheets().add( STYLESHEET_XML );
- appScene.windowProperty().addListener(
- ( observable, 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 );
- getActiveFileEditor().getEditorPane().requestFocus();
- break;
- }
- } );
-
- // Remove when the input field loses focus.
- input.focusedProperty().addListener(
- (
- final ObservableValue<? extends Boolean> focused,
- final Boolean oFocus,
- final Boolean nFocus ) -> {
- if( !nFocus ) {
- 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 ) -> {
- // Indirectly refresh the resolved map.
- resetProcessors();
-
- openDefinitions( newPath );
-
- // Will create new processors and therefore a new resolved map.
- refreshActiveTab();
- }
- );
- }
-
- /**
- * 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 );
- initKeyboardEventListener( tab );
-// initSyntaxListener( tab );
- }
- }
- }
- }
- );
- }
-
- /**
- * Listen for new tab selection events.
- */
- private void initTabChangedListener() {
- final FileEditorTabPane editorPane = getFileEditorPane();
-
- // Update the preview pane changing tabs.
- editorPane.addTabSelectionListener(
- ( ObservableValue<? extends Tab> tabPane,
- final Tab oldTab, final Tab newTab ) -> {
-
- // If there was no old tab, then this is a first time load, which
- // can be ignored.
- if( oldTab != null ) {
- if( newTab == null ) {
- closeRemainingTab();
- }
- else {
- final FileEditorTab tab = (FileEditorTab) newTab;
- updateVariableNameInjector( tab );
- refreshSelectedTab( tab );
- }
- }
- }
- );
- }
-
- /**
- * Reloads the preferences from the previous session.
- */
- private void restorePreferences() {
- restoreDefinitionPane();
- getFileEditorPane().restorePreferences();
- }
-
- private void initVariableNameInjector() {
- updateVariableNameInjector( getActiveFileEditor() );
- }
-
- /**
- * Ensure that the keyboard events are received when a new tab is added
- * to the user interface.
- *
- * @param tab The tab that can trigger keyboard events, such as
- * control+space.
- */
- private void initKeyboardEventListener( final FileEditorTab tab ) {
- tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler );
- }
-
- private void initTextChangeListener( final FileEditorTab tab ) {
- tab.addTextChangeListener(
- ( ObservableValue<? extends String> editor,
- final String oldValue, final String newValue ) ->
- refreshSelectedTab( tab )
- );
- }
-
- private void updateVariableNameInjector( final FileEditorTab tab ) {
- getVariableNameInjector().addListener( tab );
- }
-
- private VariableNameInjector getVariableNameInjector() {
- return mVariableNameInjector;
- }
-
- /**
- * 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 refreshSelectedTab( final FileEditorTab tab ) {
- if( tab == null ) {
- return;
- }
-
- getPreviewPane().setPath( tab.getPath() );
-
- Processor<String> processor = getProcessors().get( tab );
-
- if( processor == null ) {
- processor = createProcessor( tab );
- getProcessors().put( tab, processor );
- }
-
- try {
- processor.processChain( tab.getEditorText() );
- } catch( final Exception ex ) {
- error( ex );
- }
- }
-
- private void refreshActiveTab() {
- refreshSelectedTab( getActiveFileEditor() );
- }
-
- /**
- * 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 DefinitionSource ds = createDefinitionSource( path );
- setDefinitionSource( ds );
- getUserPreferences().definitionPathProperty().setValue( path.toFile() );
- getUserPreferences().save();
-
- final Tooltip tooltipPath = new Tooltip( path.toString() );
- tooltipPath.setShowDelay( Duration.millis( 200 ) );
-
- final DefinitionPane pane = getDefinitionPane();
- pane.update( ds );
- pane.addTreeChangeHandler( mTreeHandler );
- pane.addKeyEventHandler( mDefinitionKeyHandler );
- pane.filenameProperty().setValue( path.getFileName().toString() );
- pane.setTooltip( tooltipPath );
-
- interpolateResolvedMap();
- } catch( final Exception e ) {
- error( e );
- }
- }
-
- private void exportDefinitions( final Path path ) {
- try {
- final DefinitionPane pane = getDefinitionPane();
- final TreeItem<String> root = pane.getTreeView().getRoot();
- final TreeItem<String> problemChild = pane.isTreeWellFormed();
-
- if( problemChild == null ) {
- getDefinitionSource().getTreeAdapter().export( root, path );
- getNotifier().clear();
- }
- else {
- final String msg = get(
- "yaml.error.tree.form", problemChild.getValue() );
- getNotifier().notify( msg );
- }
- } catch( final Exception e ) {
- error( e );
- }
- }
-
- private void interpolateResolvedMap() {
- final Map<String, String> treeMap = getDefinitionPane().toMap();
- final Map<String, String> map = new HashMap<>( treeMap );
- MapInterpolator.interpolate( map );
-
- getResolvedMap().clear();
- getResolvedMap().putAll( map );
- }
-
- private void restoreDefinitionPane() {
- openDefinitions( getDefinitionPath() );
- }
-
- /**
- * Called when the last open tab is closed to clear the preview pane.
- */
- private void closeRemainingTab() {
- getPreviewPane().clear();
- }
-
- /**
- * Called when an exception occurs that warrants the user's attention.
- *
- * @param e The exception with a message that the user should know about.
- */
- private void error( final Exception e ) {
- getNotifier().notify( e );
- }
-
- //---- 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 != null ) {
- if( observable instanceof Snitch && value instanceof Path ) {
- updateSelectedTab();
- }
- else if( observable instanceof Notifier && value instanceof String ) {
- updateStatusBar( (String) value );
- }
- }
- }
-
- /**
- * Updates the status bar to show the given message.
- *
- * @param s The message to show in the status bar.
- */
- private void updateStatusBar( final String s ) {
- Platform.runLater(
- () -> {
- final int index = s.indexOf( '\n' );
- final String message = s.substring(
- 0, index > 0 ? index : s.length() );
-
- getStatusBar().setText( message );
- }
- );
- }
-
- /**
- * Called when a file has been modified.
- */
- private void updateSelectedTab() {
- Platform.runLater(
- () -> {
- // Brute-force XSLT file reload by re-instantiating all processors.
- resetProcessors();
- refreshActiveTab();
- }
- );
- }
-
- /**
- * 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( getActiveFileEditor(), 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 fileSave() {
- getFileEditorPane().saveEditor( getActiveFileEditor() );
- }
-
- private void fileSaveAs() {
- final FileEditorTab editor = getActiveFileEditor();
- getFileEditorPane().saveEditorAs( editor );
- getProcessors().remove( editor );
-
- try {
- refreshSelectedTab( editor );
- } catch( final Exception ex ) {
- getNotifier().notify( ex );
- }
- }
-
- private void fileSaveAll() {
- getFileEditorPane().saveAllEditors();
- }
-
- 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() {
- getActiveFileEditor().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 ) {
- getActiveEditor().surroundSelection( leading, trailing );
- }
-
- @SuppressWarnings("SameParameterValue")
- private void insertMarkdown(
- final String leading, final String trailing, final String hint ) {
- getActiveEditor().surroundSelection( leading, trailing, hint );
- }
-
- //---- Help actions -------------------------------------------------------
-
- private void helpAbout() {
- final Alert alert = new Alert( AlertType.INFORMATION );
- alert.setTitle( get( "Dialog.about.title" ) );
- alert.setHeaderText( get( "Dialog.about.header" ) );
- alert.setContentText( get( "Dialog.about.content" ) );
- alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
- alert.initOwner( getWindow() );
-
- alert.showAndWait();
- }
-
- //---- Member creators ----------------------------------------------------
-
- /**
- * Factory to create processors that are suited to 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> createProcessor( final FileEditorTab tab ) {
- return createProcessorFactory().createProcessor( tab );
- }
-
- private ProcessorFactory createProcessorFactory() {
- return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
- }
-
- private HTMLPreviewPane createHTMLPreviewPane() {
- return new HTMLPreviewPane();
- }
-
- private DefinitionSource createDefaultDefinitionSource() {
- return new YamlDefinitionSource( getDefinitionPath() );
- }
-
- private DefinitionSource createDefinitionSource( final Path path ) {
- try {
- return createDefinitionFactory().createDefinitionSource( path );
- } catch( final Exception ex ) {
- error( ex );
- return createDefaultDefinitionSource();
- }
- }
-
- private TextField createFindTextField() {
- return new TextField();
- }
-
- /**
- * Create an editor pane to hold file editor tabs.
- *
- * @return A new instance, never null.
- */
- private FileEditorTabPane createFileEditorPane() {
- return new FileEditorTabPane( mScrollEventObserver, mCaretListener );
- }
-
- private DefinitionFactory createDefinitionFactory() {
- return new DefinitionFactory();
- }
-
- private StatusBar createStatusBar() {
- return new StatusBar();
- }
-
- private Scene createScene() {
- final SplitPane splitPane = new SplitPane(
- getDefinitionPane().getNode(),
- getFileEditorPane().getNode(),
- getPreviewPane().getNode() );
-
- splitPane.setDividerPositions(
- getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
- getFloat( K_PANE_SPLIT_EDITOR, .45f ),
- getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
-
- getDefinitionPane().prefHeightProperty()
- .bind( splitPane.heightProperty() );
-
- final BorderPane borderPane = new BorderPane();
- borderPane.setPrefSize( 1024, 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 );
-
- 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 = new ActionBuilder()
- .setText( "Main.menu.file.new" )
- .setAccelerator( "Shortcut+N" )
- .setIcon( FILE_ALT )
- .setAction( e -> fileNew() )
- .build();
- final Action fileOpenAction = new ActionBuilder()
- .setText( "Main.menu.file.open" )
- .setAccelerator( "Shortcut+O" )
- .setIcon( FOLDER_OPEN_ALT )
- .setAction( e -> fileOpen() )
- .build();
- final Action fileCloseAction = new ActionBuilder()
- .setText( "Main.menu.file.close" )
- .setAccelerator( "Shortcut+W" )
- .setAction( e -> fileClose() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action fileCloseAllAction = new ActionBuilder()
- .setText( "Main.menu.file.close_all" )
- .setAction( e -> fileCloseAll() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action fileSaveAction = new ActionBuilder()
- .setText( "Main.menu.file.save" )
- .setAccelerator( "Shortcut+S" )
- .setIcon( FLOPPY_ALT )
- .setAction( e -> fileSave() )
- .setDisable( createActiveBooleanProperty(
- FileEditorTab::modifiedProperty ).not() )
- .build();
- final Action fileSaveAsAction = new ActionBuilder()
- .setText( "Main.menu.file.save_as" )
- .setAction( e -> fileSaveAs() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action fileSaveAllAction = new ActionBuilder()
- .setText( "Main.menu.file.save_all" )
- .setAccelerator( "Shortcut+Shift+S" )
- .setAction( e -> fileSaveAll() )
- .setDisable( Bindings.not(
- getFileEditorPane().anyFileEditorModifiedProperty() ) )
- .build();
- final Action fileExitAction = new ActionBuilder()
- .setText( "Main.menu.file.exit" )
- .setAction( e -> fileExit() )
- .build();
-
- // Edit actions
- final Action editUndoAction = new ActionBuilder()
- .setText( "Main.menu.edit.undo" )
- .setAccelerator( "Shortcut+Z" )
- .setIcon( UNDO )
- .setAction( e -> getActiveEditor().undo() )
- .setDisable( createActiveBooleanProperty(
- FileEditorTab::canUndoProperty ).not() )
- .build();
- final Action editRedoAction = new ActionBuilder()
- .setText( "Main.menu.edit.redo" )
- .setAccelerator( "Shortcut+Y" )
- .setIcon( REPEAT )
- .setAction( e -> getActiveEditor().redo() )
- .setDisable( createActiveBooleanProperty(
- FileEditorTab::canRedoProperty ).not() )
- .build();
- final Action editFindAction = new ActionBuilder()
- .setText( "Main.menu.edit.find" )
- .setAccelerator( "Ctrl+F" )
- .setIcon( SEARCH )
- .setAction( e -> editFind() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editFindNextAction = new ActionBuilder()
- .setText( "Main.menu.edit.find.next" )
- .setAccelerator( "F3" )
- .setIcon( null )
- .setAction( e -> editFindNext() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action editPreferencesAction = new ActionBuilder()
- .setText( "Main.menu.edit.preferences" )
- .setAccelerator( "Ctrl+Alt+S" )
- .setAction( e -> editPreferences() )
- .build();
-
- // Insert actions
- final Action insertBoldAction = new ActionBuilder()
- .setText( "Main.menu.insert.bold" )
- .setAccelerator( "Shortcut+B" )
- .setIcon( BOLD )
- .setAction( e -> insertMarkdown( "**", "**" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertItalicAction = new ActionBuilder()
- .setText( "Main.menu.insert.italic" )
- .setAccelerator( "Shortcut+I" )
- .setIcon( ITALIC )
- .setAction( e -> insertMarkdown( "*", "*" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertSuperscriptAction = new ActionBuilder()
- .setText( "Main.menu.insert.superscript" )
- .setAccelerator( "Shortcut+[" )
- .setIcon( SUPERSCRIPT )
- .setAction( e -> insertMarkdown( "^", "^" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertSubscriptAction = new ActionBuilder()
- .setText( "Main.menu.insert.subscript" )
- .setAccelerator( "Shortcut+]" )
- .setIcon( SUBSCRIPT )
- .setAction( e -> insertMarkdown( "~", "~" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertStrikethroughAction = new ActionBuilder()
- .setText( "Main.menu.insert.strikethrough" )
- .setAccelerator( "Shortcut+T" )
- .setIcon( STRIKETHROUGH )
- .setAction( e -> insertMarkdown( "~~", "~~" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertBlockquoteAction = new ActionBuilder()
- .setText( "Main.menu.insert.blockquote" )
- .setAccelerator( "Ctrl+Q" )
- .setIcon( QUOTE_LEFT )
- .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertCodeAction = new ActionBuilder()
- .setText( "Main.menu.insert.code" )
- .setAccelerator( "Shortcut+K" )
- .setIcon( CODE )
- .setAction( e -> insertMarkdown( "`", "`" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertFencedCodeBlockAction = new ActionBuilder()
- .setText( "Main.menu.insert.fenced_code_block" )
- .setAccelerator( "Shortcut+Shift+K" )
- .setIcon( FILE_CODE_ALT )
- .setAction( e -> getActiveEditor().surroundSelection(
- "\n\n```\n",
- "\n```\n\n",
- get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertLinkAction = new ActionBuilder()
- .setText( "Main.menu.insert.link" )
- .setAccelerator( "Shortcut+L" )
- .setIcon( LINK )
- .setAction( e -> getActiveEditor().insertLink() )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertImageAction = new ActionBuilder()
- .setText( "Main.menu.insert.image" )
- .setAccelerator( "Shortcut+G" )
- .setIcon( PICTURE_ALT )
- .setAction( e -> getActiveEditor().insertImage() )
- .setDisable( activeFileEditorIsNull )
- .build();
-
- // Number of header actions (H1 ... H3)
- final int HEADERS = 3;
- final Action[] headers = new Action[ HEADERS ];
-
- for( int i = 1; i <= HEADERS; 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.header." + i;
- final String accelerator = "Shortcut+" + i;
- final String prompt = text + ".prompt";
-
- headers[ i - 1 ] = new ActionBuilder()
- .setText( text )
- .setAccelerator( accelerator )
- .setIcon( HEADER )
- .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- }
-
- final Action insertUnorderedListAction = new ActionBuilder()
- .setText( "Main.menu.insert.unordered_list" )
- .setAccelerator( "Shortcut+U" )
- .setIcon( LIST_UL )
- .setAction( e -> getActiveEditor()
- .surroundSelection( "\n\n* ", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertOrderedListAction = new ActionBuilder()
- .setText( "Main.menu.insert.ordered_list" )
- .setAccelerator( "Shortcut+Shift+O" )
- .setIcon( LIST_OL )
- .setAction( e -> insertMarkdown(
- "\n\n1. ", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
- final Action insertHorizontalRuleAction = new ActionBuilder()
- .setText( "Main.menu.insert.horizontal_rule" )
- .setAccelerator( "Shortcut+H" )
- .setAction( e -> insertMarkdown(
- "\n\n---\n\n", "" ) )
- .setDisable( activeFileEditorIsNull )
- .build();
-
- // Help actions
- final Action helpAboutAction = new ActionBuilder()
- .setText( "Main.menu.help.about" )
- .setAction( e -> helpAbout() )
- .build();
-
- //---- MenuBar ----
- final Menu fileMenu = ActionUtils.createMenu(
- get( "Main.menu.file" ),
- fileNewAction,
- fileOpenAction,
- null,
- fileCloseAction,
- fileCloseAllAction,
- null,
- fileSaveAction,
- fileSaveAsAction,
- fileSaveAllAction,
- null,
- fileExitAction );
-
- final Menu editMenu = ActionUtils.createMenu(
- get( "Main.menu.edit" ),
- editUndoAction,
- editRedoAction,
- editFindAction,
- editFindNextAction,
- null,
- editPreferencesAction );
-
- final Menu insertMenu = ActionUtils.createMenu(
- get( "Main.menu.insert" ),
- insertBoldAction,
- insertItalicAction,
- insertSuperscriptAction,
- insertSubscriptAction,
- insertStrikethroughAction,
- insertBlockquoteAction,
- insertCodeAction,
- insertFencedCodeBlockAction,
- null,
- insertLinkAction,
- insertImageAction,
- null,
- headers[ 0 ],
- headers[ 1 ],
- headers[ 2 ],
- null,
- insertUnorderedListAction,
- insertOrderedListAction,
- insertHorizontalRuleAction );
-
- final Menu helpMenu = ActionUtils.createMenu(
- get( "Main.menu.help" ),
- helpAboutAction );
-
- final MenuBar menuBar = new MenuBar(
- fileMenu,
- editMenu,
- insertMenu,
- helpMenu );
-
- //---- ToolBar ----
- final ToolBar toolBar = ActionUtils.createToolBar(
- fileNewAction,
- fileOpenAction,
- fileSaveAction,
- null,
- editUndoAction,
- editRedoAction,
- null,
- insertBoldAction,
- insertItalicAction,
- insertSuperscriptAction,
- insertSubscriptAction,
- insertBlockquoteAction,
- insertCodeAction,
- insertFencedCodeBlockAction,
- null,
- insertLinkAction,
- insertImageAction,
- null,
- headers[ 0 ],
- null,
- insertUnorderedListAction,
- insertOrderedListAction );
-
- return new VBox( menuBar, toolBar );
- }
-
- /**
- * 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 = getActiveFileEditor();
-
- if( tab != null ) {
- b.bind( func.apply( tab ) );
- }
-
- getFileEditorPane().activeFileEditorProperty().addListener(
- ( observable, oldFileEditor, newFileEditor ) -> {
- b.unbind();
-
- if( newFileEditor == null ) {
- b.set( false );
- }
- else {
- b.bind( func.apply( newFileEditor ) );
- }
- }
- );
-
- return b;
- }
-
- //---- Convenience accessors ----------------------------------------------
-
- private Preferences getPreferences() {
- return OPTIONS.getState();
- }
-
- private float getFloat( final String key, final float defaultValue ) {
- return getPreferences().getFloat( key, defaultValue );
- }
-
- public Window getWindow() {
- return getScene().getWindow();
- }
-
- private MarkdownEditorPane getActiveEditor() {
- final EditorPane pane = getActiveFileEditor().getEditorPane();
-
- return pane instanceof MarkdownEditorPane
- ? (MarkdownEditorPane) pane
- : new MarkdownEditorPane();
- }
-
- private FileEditorTab getActiveFileEditor() {
- return getFileEditorPane().getActiveFileEditor();
- }
-
- //---- Member accessors ---------------------------------------------------
-
- protected Scene getScene() {
- return mScene;
- }
-
- private Map<FileEditorTab, Processor<String>> getProcessors() {
- return mProcessors;
- }
-
- private FileEditorTabPane getFileEditorPane() {
- return mFileEditorPane;
- }
-
- private HTMLPreviewPane 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;
- }
-
- /**
- * Returns the variable map of interpolated definitions.
- *
- * @return A map to help dereference variables.
- */
- private Map<String, String> getResolvedMap() {
- return mResolvedMap;
- }
-
- private Notifier getNotifier() {
- return NOTIFIER;
- }
-
- //---- Persistence accessors ----------------------------------------------
- private UserPreferences getUserPreferences() {
- return OPTIONS.getUserPreferences();
- }
-
- private Path getDefinitionPath() {
- return getUserPreferences().getDefinitionPath();
- }
-
- //---- Time accessors -----------------------------------------------------
-
- /**
- * Gets the current time in milliseconds.
- *
- * @return The value returned by {@link System#currentTimeMillis()}.
- */
- private static long now() {
- return System.currentTimeMillis();
+import javafx.scene.control.skin.ScrollBarSkin;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
+import javafx.scene.text.Text;
+import javafx.stage.Window;
+import javafx.stage.WindowEvent;
+import javafx.util.Duration;
+import org.controlsfx.control.StatusBar;
+import org.fxmisc.flowless.VirtualizedScrollPane;
+import org.fxmisc.richtext.StyleClassedTextArea;
+import org.reactfx.value.Val;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Observable;
+import java.util.Observer;
+import java.util.function.Function;
+import java.util.prefs.Preferences;
+
+import static com.scrivenvar.Constants.*;
+import static com.scrivenvar.Messages.get;
+import static com.scrivenvar.util.StageState.*;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
+import static javafx.event.Event.fireEvent;
+import static javafx.geometry.Orientation.VERTICAL;
+import static javafx.scene.input.KeyCode.ENTER;
+import static javafx.scene.input.KeyCode.TAB;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+
+/**
+ * Main window containing a tab pane in the center for file editors.
+ *
+ * @author Karl Tauber and White Magic Software, Ltd.
+ */
+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 final static Options OPTIONS = Services.load( Options.class );
+ private final static Snitch SNITCH = Services.load( Snitch.class );
+ private final static Notifier NOTIFIER = Services.load( Notifier.class );
+
+ private final Scene mScene;
+ private final StatusBar mStatusBar;
+ private final Text mLineNumberText;
+ private final TextField mFindTextField;
+
+ 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 );
+
+ /**
+ * Called when the definition data is changed.
+ */
+ private final EventHandler<TreeItem.TreeModificationEvent<Event>>
+ mTreeHandler = event -> {
+ exportDefinitions( getDefinitionPath() );
+ interpolateResolvedMap();
+ renderActiveTab();
+ };
+
+ /**
+ * Called to switch to the definition pane when the user presses the TAB key.
+ */
+ private final EventHandler<? super KeyEvent> mTabKeyHandler =
+ (EventHandler<KeyEvent>) event -> {
+ if( event.getCode() == TAB ) {
+ getDefinitionPane().requestFocus();
+ event.consume();
+ }
+ };
+
+ /**
+ * 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 ) {
+ getVariableNameInjector().injectSelectedItem();
+ }
+ };
+
+ private final ChangeListener<Integer> mCaretPositionListener =
+ ( observable, oldPosition, newPosition ) -> {
+ final FileEditorTab tab = getActiveFileEditorTab();
+ final EditorPane pane = tab.getEditorPane();
+ final StyleClassedTextArea editor = pane.getEditor();
+
+ getLineNumberText().setText(
+ get( STATUS_BAR_LINE,
+ editor.getCurrentParagraph() + 1,
+ editor.getParagraphs().size(),
+ editor.getCaretPosition()
+ )
+ );
+ };
+
+ private final ChangeListener<Integer> mCaretParagraphListener =
+ ( observable, oldIndex, newIndex ) ->
+ scrollToParagraph( newIndex, true );
+
+ private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
+ private final DefinitionPane mDefinitionPane = new DefinitionPane();
+ private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
+ private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
+ mCaretPositionListener,
+ mCaretParagraphListener );
+
+ /**
+ * Listens on the definition pane for double-click events.
+ */
+ private final VariableNameInjector mVariableNameInjector
+ = new VariableNameInjector( mDefinitionPane );
+
+ public MainWindow() {
+ mStatusBar = createStatusBar();
+ mLineNumberText = createLineNumberText();
+ mFindTextField = createFindTextField();
+ mScene = createScene();
+
+ initLayout();
+ initFindInput();
+ initSnitch();
+ initDefinitionListener();
+ initTabAddedListener();
+ initTabChangedListener();
+ initPreferences();
+ initVariableNameInjector();
+
+ NOTIFIER.addObserver( this );
+ }
+
+ private void initLayout() {
+ final Scene appScene = getScene();
+
+ appScene.getStylesheets().add( STYLESHEET_SCENE );
+
+ // TODO: Apply an XML syntax highlighting for XML files.
+// appScene.getStylesheets().add( STYLESHEET_XML );
+ appScene.windowProperty().addListener(
+ ( observable, 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 ) -> {
+ // Indirectly refresh the resolved map.
+ resetProcessors();
+
+ openDefinitions( newPath );
+
+ // Will create new processors and therefore a new resolved map.
+ renderActiveTab();
+ }
+ );
+ }
+
+ /**
+ * 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 );
+ initTabKeyEventListener( tab );
+ initScrollEventListener( tab );
+// initSyntaxListener( tab );
+ }
+ }
+ }
+ }
+ );
+ }
+
+ private void initScrollEventListener( final FileEditorTab tab ) {
+ final var scrollPane = tab.getEditorPane().getScrollPane();
+ final var scrollBar = getPreviewPane().getVerticalScrollBar();
+
+ final ChangeListener<? super Boolean> listener = ( ob, o, newShow ) ->
+ Platform.runLater( () -> {
+ if( newShow ) {
+ new ScrollBarDragHandler( scrollPane, scrollBar );
+ }
+ } );
+
+ Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
+ .flatMap( Window::showingProperty )
+ .addListener( listener );
+ }
+
+ /**
+ * Listen for new tab selection events.
+ */
+ private void initTabChangedListener() {
+ final FileEditorTabPane editorPane = getFileEditorPane();
+
+ // Update the preview pane changing tabs.
+ editorPane.addTabSelectionListener(
+ ( tabPane, oldTab, newTab ) -> {
+ // If there was no old tab, then this is a first time load, which
+ // can be ignored.
+ if( oldTab != null ) {
+ if( newTab != null ) {
+ final FileEditorTab tab = (FileEditorTab) newTab;
+ updateVariableNameInjector( tab );
+ process( tab );
+ }
+ }
+ }
+ );
+ }
+
+ /**
+ * Reloads the preferences from the previous session.
+ */
+ private void initPreferences() {
+ initDefinitionPane();
+ getFileEditorPane().initPreferences();
+ }
+
+ private void initVariableNameInjector() {
+ updateVariableNameInjector( getActiveFileEditorTab() );
+ }
+
+ /**
+ * Ensure that the keyboard events are received when a new tab is added
+ * to the user interface.
+ *
+ * @param tab The tab editor that can trigger keyboard events.
+ */
+ private void initTabKeyEventListener( final FileEditorTab tab ) {
+ tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
+ }
+
+ private void initTextChangeListener( final FileEditorTab tab ) {
+ tab.addTextChangeListener(
+ ( editor, oldValue, newValue ) -> {
+ process( tab );
+ scrollToParagraph( getCurrentParagraphIndex() );
+ }
+ );
+ }
+
+ private int getCurrentParagraphIndex() {
+ return getActiveEditorPane().getCurrentParagraphIndex();
+ }
+
+ private void scrollToParagraph( final int id ) {
+ scrollToParagraph( id, false );
+ }
+
+ /**
+ * @param id The paragraph to scroll to, will be approximated if it doesn't
+ * exist.
+ * @param force {@code true} means to force scrolling immediately, which
+ * should only be attempted when it is known that the document
+ * has been fully rendered. Otherwise the internal map of ID
+ * attributes will be incomplete and scrolling will flounder.
+ */
+ private void scrollToParagraph( final int id, final boolean force ) {
+ synchronized( mMutex ) {
+ final var previewPane = getPreviewPane();
+ final var scrollPane = previewPane.getScrollPane();
+ final int approxId = getActiveEditorPane().approximateParagraphId( id );
+
+ if( force ) {
+ previewPane.scrollTo( approxId );
+ }
+ else {
+ previewPane.tryScrollTo( approxId );
+ }
+
+ scrollPane.repaint();
+ }
+ }
+
+ private void updateVariableNameInjector( final FileEditorTab tab ) {
+ getVariableNameInjector().addListener( tab );
+ }
+
+ /**
+ * 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 ) {
+ return;
+ }
+
+ getPreviewPane().setPath( tab.getPath() );
+
+ final Processor<String> processor = getProcessors().computeIfAbsent(
+ tab, p -> createProcessor( tab )
+ );
+
+ try {
+ processor.processChain( tab.getEditorText() );
+ } catch( final Exception ex ) {
+ error( ex );
+ }
+ }
+
+ private void renderActiveTab() {
+ 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 DefinitionSource ds = createDefinitionSource( path );
+ setDefinitionSource( ds );
+ getUserPreferences().definitionPathProperty().setValue( path.toFile() );
+ getUserPreferences().save();
+
+ final Tooltip tooltipPath = new Tooltip( path.toString() );
+ tooltipPath.setShowDelay( Duration.millis( 200 ) );
+
+ final DefinitionPane pane = getDefinitionPane();
+ pane.update( ds );
+ pane.addTreeChangeHandler( mTreeHandler );
+ pane.addKeyEventHandler( mDefinitionKeyHandler );
+ pane.filenameProperty().setValue( path.getFileName().toString() );
+ pane.setTooltip( tooltipPath );
+
+ interpolateResolvedMap();
+ } catch( final Exception e ) {
+ error( e );
+ }
+ }
+
+ private void exportDefinitions( final Path path ) {
+ try {
+ final DefinitionPane pane = getDefinitionPane();
+ final TreeItem<String> root = pane.getTreeView().getRoot();
+ final TreeItem<String> problemChild = pane.isTreeWellFormed();
+
+ if( problemChild == null ) {
+ getDefinitionSource().getTreeAdapter().export( root, path );
+ getNotifier().clear();
+ }
+ else {
+ final String msg = get(
+ "yaml.error.tree.form", problemChild.getValue() );
+ getNotifier().notify( msg );
+ }
+ } catch( final Exception e ) {
+ error( e );
+ }
+ }
+
+ private void interpolateResolvedMap() {
+ final Map<String, String> treeMap = getDefinitionPane().toMap();
+ final Map<String, String> map = new HashMap<>( treeMap );
+ MapInterpolator.interpolate( map );
+
+ getResolvedMap().clear();
+ getResolvedMap().putAll( map );
+ }
+
+ private void initDefinitionPane() {
+ openDefinitions( getDefinitionPath() );
+ }
+
+ /**
+ * Called when an exception occurs that warrants the user's attention.
+ *
+ * @param e The exception with a message that the user should know about.
+ */
+ private void error( final Exception e ) {
+ getNotifier().notify( e );
+ }
+
+ //---- 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 != null ) {
+ if( observable instanceof Snitch && value instanceof Path ) {
+ updateSelectedTab();
+ }
+ else if( observable instanceof Notifier && value instanceof String ) {
+ updateStatusBar( (String) value );
+ }
+ }
+ }
+
+ /**
+ * Updates the status bar to show the given message.
+ *
+ * @param s The message to show in the status bar.
+ */
+ private void updateStatusBar( final String s ) {
+ Platform.runLater(
+ () -> {
+ final int index = s.indexOf( '\n' );
+ final String message = s.substring(
+ 0, index > 0 ? index : s.length() );
+
+ getStatusBar().setText( message );
+ }
+ );
+ }
+
+ /**
+ * Called when a file has been modified.
+ */
+ private void updateSelectedTab() {
+ Platform.runLater(
+ () -> {
+ // Brute-force XSLT file reload by re-instantiating all processors.
+ resetProcessors();
+ renderActiveTab();
+ }
+ );
+ }
+
+ /**
+ * 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 fileSave() {
+ getFileEditorPane().saveEditor( getActiveFileEditorTab() );
+ }
+
+ private void fileSaveAs() {
+ final FileEditorTab editor = getActiveFileEditorTab();
+ getFileEditorPane().saveEditorAs( editor );
+ getProcessors().remove( editor );
+
+ try {
+ process( editor );
+ } catch( final Exception ex ) {
+ getNotifier().notify( ex );
+ }
+ }
+
+ private void fileSaveAll() {
+ getFileEditorPane().saveAllEditors();
+ }
+
+ 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 );
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ 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( AlertType.INFORMATION );
+ alert.setTitle( get( "Dialog.about.title" ) );
+ alert.setHeaderText( get( "Dialog.about.header" ) );
+ alert.setContentText( get( "Dialog.about.content" ) );
+ alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
+ alert.initOwner( getWindow() );
+
+ alert.showAndWait();
+ }
+
+ //---- Member creators ----------------------------------------------------
+
+ /**
+ * Factory to create processors that are suited to 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> createProcessor( final FileEditorTab tab ) {
+ return createProcessorFactory().createProcessor( tab );
+ }
+
+ private ProcessorFactory createProcessorFactory() {
+ return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
+ }
+
+ private HTMLPreviewPane createHTMLPreviewPane() {
+ return new HTMLPreviewPane();
+ }
+
+ private DefinitionSource createDefaultDefinitionSource() {
+ return new YamlDefinitionSource( getDefinitionPath() );
+ }
+
+ private DefinitionSource createDefinitionSource( final Path path ) {
+ try {
+ return createDefinitionFactory().createDefinitionSource( path );
+ } catch( final Exception ex ) {
+ error( 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 SplitPane splitPane = new SplitPane(
+ getDefinitionPane().getNode(),
+ getFileEditorPane().getNode(),
+ getPreviewPane().getNode() );
+
+ splitPane.setDividerPositions(
+ getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
+ getFloat( K_PANE_SPLIT_EDITOR, .45f ),
+ getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
+
+ getDefinitionPane().prefHeightProperty()
+ .bind( splitPane.heightProperty() );
+
+ final BorderPane borderPane = new BorderPane();
+ borderPane.setPrefSize( 1024, 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 );
+
+ 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 = new ActionBuilder()
+ .setText( "Main.menu.file.new" )
+ .setAccelerator( "Shortcut+N" )
+ .setIcon( FILE_ALT )
+ .setAction( e -> fileNew() )
+ .build();
+ final Action fileOpenAction = new ActionBuilder()
+ .setText( "Main.menu.file.open" )
+ .setAccelerator( "Shortcut+O" )
+ .setIcon( FOLDER_OPEN_ALT )
+ .setAction( e -> fileOpen() )
+ .build();
+ final Action fileCloseAction = new ActionBuilder()
+ .setText( "Main.menu.file.close" )
+ .setAccelerator( "Shortcut+W" )
+ .setAction( e -> fileClose() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action fileCloseAllAction = new ActionBuilder()
+ .setText( "Main.menu.file.close_all" )
+ .setAction( e -> fileCloseAll() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action fileSaveAction = new ActionBuilder()
+ .setText( "Main.menu.file.save" )
+ .setAccelerator( "Shortcut+S" )
+ .setIcon( FLOPPY_ALT )
+ .setAction( e -> fileSave() )
+ .setDisable( createActiveBooleanProperty(
+ FileEditorTab::modifiedProperty ).not() )
+ .build();
+ final Action fileSaveAsAction = new ActionBuilder()
+ .setText( "Main.menu.file.save_as" )
+ .setAction( e -> fileSaveAs() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action fileSaveAllAction = new ActionBuilder()
+ .setText( "Main.menu.file.save_all" )
+ .setAccelerator( "Shortcut+Shift+S" )
+ .setAction( e -> fileSaveAll() )
+ .setDisable( Bindings.not(
+ getFileEditorPane().anyFileEditorModifiedProperty() ) )
+ .build();
+ final Action fileExitAction = new ActionBuilder()
+ .setText( "Main.menu.file.exit" )
+ .setAction( e -> fileExit() )
+ .build();
+
+ // Edit actions
+ final Action editUndoAction = new ActionBuilder()
+ .setText( "Main.menu.edit.undo" )
+ .setAccelerator( "Shortcut+Z" )
+ .setIcon( UNDO )
+ .setAction( e -> getActiveEditorPane().undo() )
+ .setDisable( createActiveBooleanProperty(
+ FileEditorTab::canUndoProperty ).not() )
+ .build();
+ final Action editRedoAction = new ActionBuilder()
+ .setText( "Main.menu.edit.redo" )
+ .setAccelerator( "Shortcut+Y" )
+ .setIcon( REPEAT )
+ .setAction( e -> getActiveEditorPane().redo() )
+ .setDisable( createActiveBooleanProperty(
+ FileEditorTab::canRedoProperty ).not() )
+ .build();
+ final Action editFindAction = new ActionBuilder()
+ .setText( "Main.menu.edit.find" )
+ .setAccelerator( "Ctrl+F" )
+ .setIcon( SEARCH )
+ .setAction( e -> editFind() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action editFindNextAction = new ActionBuilder()
+ .setText( "Main.menu.edit.find.next" )
+ .setAccelerator( "F3" )
+ .setIcon( null )
+ .setAction( e -> editFindNext() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action editPreferencesAction = new ActionBuilder()
+ .setText( "Main.menu.edit.preferences" )
+ .setAccelerator( "Ctrl+Alt+S" )
+ .setAction( e -> editPreferences() )
+ .build();
+
+ // Insert actions
+ final Action insertBoldAction = new ActionBuilder()
+ .setText( "Main.menu.insert.bold" )
+ .setAccelerator( "Shortcut+B" )
+ .setIcon( BOLD )
+ .setAction( e -> insertMarkdown( "**", "**" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertItalicAction = new ActionBuilder()
+ .setText( "Main.menu.insert.italic" )
+ .setAccelerator( "Shortcut+I" )
+ .setIcon( ITALIC )
+ .setAction( e -> insertMarkdown( "*", "*" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertSuperscriptAction = new ActionBuilder()
+ .setText( "Main.menu.insert.superscript" )
+ .setAccelerator( "Shortcut+[" )
+ .setIcon( SUPERSCRIPT )
+ .setAction( e -> insertMarkdown( "^", "^" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertSubscriptAction = new ActionBuilder()
+ .setText( "Main.menu.insert.subscript" )
+ .setAccelerator( "Shortcut+]" )
+ .setIcon( SUBSCRIPT )
+ .setAction( e -> insertMarkdown( "~", "~" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertStrikethroughAction = new ActionBuilder()
+ .setText( "Main.menu.insert.strikethrough" )
+ .setAccelerator( "Shortcut+T" )
+ .setIcon( STRIKETHROUGH )
+ .setAction( e -> insertMarkdown( "~~", "~~" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertBlockquoteAction = new ActionBuilder()
+ .setText( "Main.menu.insert.blockquote" )
+ .setAccelerator( "Ctrl+Q" )
+ .setIcon( QUOTE_LEFT )
+ .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertCodeAction = new ActionBuilder()
+ .setText( "Main.menu.insert.code" )
+ .setAccelerator( "Shortcut+K" )
+ .setIcon( CODE )
+ .setAction( e -> insertMarkdown( "`", "`" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertFencedCodeBlockAction = new ActionBuilder()
+ .setText( "Main.menu.insert.fenced_code_block" )
+ .setAccelerator( "Shortcut+Shift+K" )
+ .setIcon( FILE_CODE_ALT )
+ .setAction( e -> getActiveEditorPane().surroundSelection(
+ "\n\n```\n",
+ "\n```\n\n",
+ get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertLinkAction = new ActionBuilder()
+ .setText( "Main.menu.insert.link" )
+ .setAccelerator( "Shortcut+L" )
+ .setIcon( LINK )
+ .setAction( e -> getActiveEditorPane().insertLink() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertImageAction = new ActionBuilder()
+ .setText( "Main.menu.insert.image" )
+ .setAccelerator( "Shortcut+G" )
+ .setIcon( PICTURE_ALT )
+ .setAction( e -> getActiveEditorPane().insertImage() )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+
+ // Number of header actions (H1 ... H3)
+ final int HEADERS = 3;
+ final Action[] headers = new Action[ HEADERS ];
+
+ for( int i = 1; i <= HEADERS; 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.header." + i;
+ final String accelerator = "Shortcut+" + i;
+ final String prompt = text + ".prompt";
+
+ headers[ i - 1 ] = new ActionBuilder()
+ .setText( text )
+ .setAccelerator( accelerator )
+ .setIcon( HEADER )
+ .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ }
+
+ final Action insertUnorderedListAction = new ActionBuilder()
+ .setText( "Main.menu.insert.unordered_list" )
+ .setAccelerator( "Shortcut+U" )
+ .setIcon( LIST_UL )
+ .setAction( e -> getActiveEditorPane()
+ .surroundSelection( "\n\n* ", "" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertOrderedListAction = new ActionBuilder()
+ .setText( "Main.menu.insert.ordered_list" )
+ .setAccelerator( "Shortcut+Shift+O" )
+ .setIcon( LIST_OL )
+ .setAction( e -> insertMarkdown(
+ "\n\n1. ", "" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+ final Action insertHorizontalRuleAction = new ActionBuilder()
+ .setText( "Main.menu.insert.horizontal_rule" )
+ .setAccelerator( "Shortcut+H" )
+ .setAction( e -> insertMarkdown(
+ "\n\n---\n\n", "" ) )
+ .setDisable( activeFileEditorIsNull )
+ .build();
+
+ // Help actions
+ final Action helpAboutAction = new ActionBuilder()
+ .setText( "Main.menu.help.about" )
+ .setAction( e -> helpAbout() )
+ .build();
+
+ //---- MenuBar ----
+ final Menu fileMenu = ActionUtils.createMenu(
+ get( "Main.menu.file" ),
+ fileNewAction,
+ fileOpenAction,
+ null,
+ fileCloseAction,
+ fileCloseAllAction,
+ null,
+ fileSaveAction,
+ fileSaveAsAction,
+ fileSaveAllAction,
+ null,
+ fileExitAction );
+
+ final Menu editMenu = ActionUtils.createMenu(
+ get( "Main.menu.edit" ),
+ editUndoAction,
+ editRedoAction,
+ editFindAction,
+ editFindNextAction,
+ null,
+ editPreferencesAction );
+
+ final Menu insertMenu = ActionUtils.createMenu(
+ get( "Main.menu.insert" ),
+ insertBoldAction,
+ insertItalicAction,
+ insertSuperscriptAction,
+ insertSubscriptAction,
+ insertStrikethroughAction,
+ insertBlockquoteAction,
+ insertCodeAction,
+ insertFencedCodeBlockAction,
+ null,
+ insertLinkAction,
+ insertImageAction,
+ null,
+ headers[ 0 ],
+ headers[ 1 ],
+ headers[ 2 ],
+ null,
+ insertUnorderedListAction,
+ insertOrderedListAction,
+ insertHorizontalRuleAction );
+
+ final Menu helpMenu = ActionUtils.createMenu(
+ get( "Main.menu.help" ),
+ helpAboutAction );
+
+ final MenuBar menuBar = new MenuBar(
+ fileMenu,
+ editMenu,
+ insertMenu,
+ helpMenu );
+
+ //---- ToolBar ----
+ final ToolBar toolBar = ActionUtils.createToolBar(
+ fileNewAction,
+ fileOpenAction,
+ fileSaveAction,
+ null,
+ editUndoAction,
+ editRedoAction,
+ null,
+ insertBoldAction,
+ insertItalicAction,
+ insertSuperscriptAction,
+ insertSubscriptAction,
+ insertBlockquoteAction,
+ insertCodeAction,
+ insertFencedCodeBlockAction,
+ null,
+ insertLinkAction,
+ insertImageAction,
+ null,
+ headers[ 0 ],
+ null,
+ insertUnorderedListAction,
+ insertOrderedListAction );
+
+ return new VBox( menuBar, toolBar );
+ }
+
+ /**
+ * 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(
+ ( observable, oldFileEditor, newFileEditor ) -> {
+ b.unbind();
+
+ if( newFileEditor == null ) {
+ b.set( false );
+ }
+ else {
+ b.bind( func.apply( newFileEditor ) );
+ }
+ }
+ );
+
+ return b;
+ }
+
+ //---- Convenience accessors ----------------------------------------------
+
+ private Preferences getPreferences() {
+ return OPTIONS.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 Map<FileEditorTab, Processor<String>> getProcessors() {
+ return mProcessors;
+ }
+
+ private FileEditorTabPane getFileEditorPane() {
+ return mFileEditorPane;
+ }
+
+ private HTMLPreviewPane 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 VariableNameInjector getVariableNameInjector() {
+ return mVariableNameInjector;
+ }
+
+ /**
+ * Returns the variable map of interpolated definitions.
+ *
+ * @return A map to help dereference variables.
+ */
+ private Map<String, String> getResolvedMap() {
+ return mResolvedMap;
+ }
+
+ private Notifier getNotifier() {
+ return NOTIFIER;
+ }
+
+ //---- Persistence accessors ----------------------------------------------
+
+ private UserPreferences getUserPreferences() {
+ return OPTIONS.getUserPreferences();
+ }
+
+ private Path getDefinitionPath() {
+ return getUserPreferences().getDefinitionPath();
+ }
+
+ private StackPane getVerticalScrollBarThumb(
+ final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
+ final ScrollBar scrollBar = getVerticalScrollBar( pane );
+ final ScrollBarSkin skin = (ScrollBarSkin) (scrollBar.skinProperty().get());
+
+ for( final Node node : skin.getChildren() ) {
+ // Brittle, but what can you do?
+ if( node.getStyleClass().contains( "thumb" ) ) {
+ return (StackPane) node;
+ }
+ }
+
+ throw new IllegalArgumentException( "No scroll bar skin found." );
+ }
+
+ private ScrollBar getVerticalScrollBar(
+ final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
+
+ for( final Node node : pane.getChildrenUnmodifiable() ) {
+ if( node instanceof ScrollBar ) {
+ final ScrollBar scrollBar = (ScrollBar) node;
+
+ if( scrollBar.getOrientation() == VERTICAL ) {
+ return scrollBar;
+ }
+ }
+ }
+
+ throw new IllegalArgumentException( "No vertical scroll pane found." );
}
}
src/main/java/com/scrivenvar/ScrollBarDragHandler.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.scrivenvar;
+
+import javafx.event.EventHandler;
+import javafx.scene.Node;
+import javafx.scene.control.ScrollBar;
+import javafx.scene.control.skin.ScrollBarSkin;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.StackPane;
+import org.fxmisc.flowless.VirtualizedScrollPane;
+import org.fxmisc.richtext.StyleClassedTextArea;
+
+import javax.swing.*;
+
+import static javafx.geometry.Orientation.VERTICAL;
+
+/**
+ * Converts scroll events from {@link VirtualizedScrollPane} scroll bars to
+ * an instance of {@link JScrollBar}.
+ */
+public final class ScrollBarDragHandler implements EventHandler<MouseEvent> {
+ private final VirtualizedScrollPane<StyleClassedTextArea> mEditorScrollPane;
+ private final JScrollBar mPreviewScrollBar;
+ private final EventHandler<? super MouseEvent> mOldHandler;
+
+ /**
+ * @param editorScrollPane Scroll event source (human movement).
+ * @param previewScrollBar Scroll event destination (corresponding movement).
+ */
+ public ScrollBarDragHandler(
+ final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane,
+ final JScrollBar previewScrollBar ) {
+ mEditorScrollPane = editorScrollPane;
+ mPreviewScrollBar = previewScrollBar;
+
+// mEditorScrollPane.estimatedScrollYProperty().addObserver( c -> {
+// System.out.println("SCROLL SCROLL THE BOAT");
+// });
+
+ final var thumb = getVerticalScrollBarThumb( mEditorScrollPane );
+ mOldHandler = thumb.getOnMouseDragged();
+ thumb.setOnMouseDragged( this );
+ }
+
+ /**
+ * Called to synchronize the scrolling areas. This will suppress any
+ * scroll events that happen shortly after the user has typed a key.
+ * See {@link Constants#KEYBOARD_SCROLL_DELAY} for details.
+ */
+ @Override
+ public void handle( final MouseEvent event ) {
+ final var eScrollPane = getEditorScrollPane();
+ final int eScrollY =
+ eScrollPane.estimatedScrollYProperty().getValue().intValue();
+ final int eHeight = (int)
+ (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
+ - eScrollPane.getHeight());
+ final double eRatio = eHeight > 0
+ ? Math.min( Math.max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
+
+ final var pScrollBar = getPreviewScrollBar();
+ final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
+ final var pScrollY = (int) (pHeight * eRatio);
+
+ pScrollBar.setValue( pScrollY );
+ pScrollBar.getParent().repaint();
+ mOldHandler.handle( event );
+ }
+
+ private StackPane getVerticalScrollBarThumb(
+ final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
+ final ScrollBar scrollBar = getVerticalScrollBar( pane );
+ final ScrollBarSkin skin = (ScrollBarSkin) (scrollBar.skinProperty().get());
+
+ for( final Node node : skin.getChildren() ) {
+ // Brittle, but what can you do?
+ if( node.getStyleClass().contains( "thumb" ) ) {
+ return (StackPane) node;
+ }
+ }
+
+ throw new IllegalArgumentException( "No scroll bar skin found." );
+ }
+
+ private ScrollBar getVerticalScrollBar(
+ final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
+
+ for( final Node node : pane.getChildrenUnmodifiable() ) {
+ if( node instanceof ScrollBar ) {
+ final ScrollBar scrollBar = (ScrollBar) node;
+
+ if( scrollBar.getOrientation() == VERTICAL ) {
+ return scrollBar;
+ }
+ }
+ }
+
+ throw new IllegalArgumentException( "No vertical scroll pane found." );
+ }
+
+ private VirtualizedScrollPane<StyleClassedTextArea> getEditorScrollPane() {
+ return mEditorScrollPane;
+ }
+
+ private JScrollBar getPreviewScrollBar() {
+ return mPreviewScrollBar;
+ }
+}