Dave Jarvis' Repositories

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

Separated HTML Preview from FileEditorTabPane. Added hooks for tab listeners to update the preview pane. Added note about CARETPOSITION interfering with Markdown AST.

Authordjarvis <email>
Date2016-12-11 01:00:38 GMT-0800
Commit1efcf56fe0adaba7cce566d4d7da7978f93619ed
Parent64bbaed
src/main/java/com/scrivenvar/FileEditorTab.java
import com.scrivenvar.editor.EditorPane;
import com.scrivenvar.editor.MarkdownEditorPane;
-import com.scrivenvar.preview.HTMLPreviewPane;
-import com.scrivenvar.service.Options;
-import com.scrivenvar.service.events.AlertMessage;
-import com.scrivenvar.service.events.AlertService;
-import java.nio.charset.Charset;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import static java.util.Locale.ENGLISH;
-import java.util.function.Consumer;
-import javafx.application.Platform;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanWrapper;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.event.Event;
-import javafx.scene.control.SplitPane;
-import javafx.scene.control.Tab;
-import javafx.scene.control.Tooltip;
-import javafx.scene.input.InputEvent;
-import javafx.scene.text.Text;
-import org.fxmisc.flowless.VirtualizedScrollPane;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import org.fxmisc.undo.UndoManager;
-import org.fxmisc.wellbehaved.event.EventPattern;
-import org.fxmisc.wellbehaved.event.InputMap;
-import org.mozilla.universalchardet.UniversalDetector;
-
-/**
- * Editor for a single file.
- *
- * @author Karl Tauber and White Magic Software, Ltd.
- */
-public final class FileEditorTab extends Tab {
-
- private final Options options = Services.load( Options.class );
- private final AlertService alertService = Services.load( AlertService.class );
-
- private EditorPane editorPane;
- private HTMLPreviewPane previewPane;
-
- /**
- * Character encoding used by the file (or default encoding if none found).
- */
- private Charset encoding;
-
- private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
- private final BooleanProperty canUndo = new SimpleBooleanProperty();
- private final BooleanProperty canRedo = new SimpleBooleanProperty();
- private Path path;
-
- FileEditorTab( final Path path ) {
- setPath( path );
- setUserData( this );
-
- this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
- updateTab();
-
- setOnSelectionChanged( e -> {
- if( isSelected() ) {
- Platform.runLater( () -> 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() {
- final Path filePath = getPath();
-
- return (filePath == null)
- ? Messages.get( "FileEditor.untitled" )
- : filePath.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 (filePath == null)
- ? null
- : new Tooltip( 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 (*must* be called after load).
- initUndoManager();
- initSplitPane();
- initFocus();
- }
-
- public void initSplitPane() {
- final EditorPane editor = getEditorPane();
- final HTMLPreviewPane preview = getPreviewPane();
- final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane = editor.getScrollPane();
-
- // Make the preview pane scroll correspond to the editor pane scroll.
- // Separate the edit and preview panels.
- setContent( new SplitPane( editorScrollPane, preview.getNode() ) );
- }
-
- private void initFocus() {
- getEditorPane().requestFocus();
- }
-
- private void initUndoManager() {
- final UndoManager undoManager = getUndoManager();
-
- // Clear undo history after first load.
- undoManager.forgetHistory();
-
- // Bind the editor undo manager to the properties.
- modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
- canUndo.bind( undoManager.undoAvailableProperty() );
- canRedo.bind( undoManager.redoAvailableProperty() );
- }
-
- /**
- * 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 getEditorPane().getEditor().getCaretPosition();
- }
-
- /**
- * 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 ? false : filePath.equals( check );
- }
-
- /**
- * Reads the entire file contents from the path associated with this tab.
- */
- private void load() {
- final Path filePath = getPath();
-
- if( filePath != null ) {
- try {
- getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
- } catch( Exception ex ) {
- alert(
- "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
- );
- }
- }
- }
-
- /**
- * Saves the entire file contents from the path associated with this tab.
- *
- * @return true The file has been saved.
- */
- public boolean save() {
- try {
- Files.write( getPath(), asBytes( getEditorPane().getText() ) );
- getEditorPane().getUndoManager().mark();
- return true;
- } catch( 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
- */
- private boolean alert( String titleKey, String messageKey, Exception e ) {
- final AlertService service = getAlertService();
-
- final AlertMessage message = service.createAlertMessage(
- Messages.get( titleKey ),
- Messages.get( messageKey ),
- getPath(),
- e.getMessage()
- );
-
- service.createAlertError( message ).showAndWait();
- return false;
- }
-
- /**
- * 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 UniversalDetector detector = new UniversalDetector( null );
- detector.handleData( bytes, 0, bytes.length );
- detector.dataEnd();
-
- final String charset = detector.getDetectedCharset();
- final Charset charEncoding = charset == null
- ? Charset.defaultCharset()
- : Charset.forName( charset.toUpperCase( ENGLISH ) );
-
- detector.reset();
-
- return charEncoding;
- }
-
- /**
- * 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() );
- }
-
- Path getPath() {
- return this.path;
- }
-
- void setPath( final Path path ) {
- this.path = path;
- }
-
- public boolean isModified() {
- return this.modified.get();
- }
-
- ReadOnlyBooleanProperty modifiedProperty() {
- return this.modified.getReadOnlyProperty();
- }
-
- BooleanProperty canUndoProperty() {
- return this.canUndo;
- }
-
- BooleanProperty canRedoProperty() {
- return this.canRedo;
- }
-
- private UndoManager getUndoManager() {
- return getEditorPane().getUndoManager();
- }
-
- /**
- * Forwards the request to the editor pane.
- *
- * @param <T> The type of event listener to add.
- * @param <U> The type of consumer to add.
- * @param event The event that should trigger updates to the listener.
- * @param consumer The listener to receive update events.
- */
- public <T extends Event, U extends T> void addEventListener(
- final EventPattern<? super T, ? extends U> event,
- final Consumer<? super U> consumer ) {
- getEditorPane().addEventListener( event, consumer );
- }
-
- /**
- * Forwards to the editor pane's listeners for keyboard events.
- *
- * @param map The new input map to replace the existing keyboard listener.
- */
- public void addEventListener( final InputMap<InputEvent> map ) {
- getEditorPane().addEventListener( map );
- }
-
- /**
- * Forwards to the editor pane's listeners for keyboard events.
- *
- * @param map The existing input map to remove from the keyboard listeners.
- */
- public void removeEventListener( final InputMap<InputEvent> map ) {
- getEditorPane().removeEventListener( map );
- }
-
- /**
- * Returns the editor pane, or creates one if it doesn't yet exist.
- *
- * @return The editor pane, never null.
- */
- protected EditorPane getEditorPane() {
- if( this.editorPane == null ) {
- this.editorPane = new MarkdownEditorPane();
- }
-
- return this.editorPane;
- }
-
- private AlertService getAlertService() {
- return this.alertService;
- }
-
- private Options getOptions() {
- return this.options;
- }
-
- public HTMLPreviewPane getPreviewPane() {
- if( this.previewPane == null ) {
- this.previewPane = new HTMLPreviewPane( getPath() );
- }
-
- return this.previewPane;
+import com.scrivenvar.service.Options;
+import com.scrivenvar.service.events.AlertMessage;
+import com.scrivenvar.service.events.AlertService;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import static java.util.Locale.ENGLISH;
+import java.util.function.Consumer;
+import javafx.application.Platform;
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.event.Event;
+import javafx.scene.Node;
+import javafx.scene.control.Tab;
+import javafx.scene.control.Tooltip;
+import javafx.scene.input.InputEvent;
+import javafx.scene.text.Text;
+import org.fxmisc.undo.UndoManager;
+import org.fxmisc.wellbehaved.event.EventPattern;
+import org.fxmisc.wellbehaved.event.InputMap;
+import org.mozilla.universalchardet.UniversalDetector;
+
+/**
+ * Editor for a single file.
+ *
+ * @author Karl Tauber and White Magic Software, Ltd.
+ */
+public final class FileEditorTab extends Tab {
+
+ private final Options options = Services.load( Options.class );
+ private final AlertService alertService = Services.load( AlertService.class );
+
+ private EditorPane editorPane;
+
+ /**
+ * Character encoding used by the file (or default encoding if none found).
+ */
+ private Charset encoding;
+
+ private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
+ private final BooleanProperty canUndo = new SimpleBooleanProperty();
+ private final BooleanProperty canRedo = new SimpleBooleanProperty();
+ private Path path;
+
+ FileEditorTab( final Path path ) {
+ setPath( path );
+ setUserData( this );
+
+ this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
+ updateTab();
+
+ setOnSelectionChanged( e -> {
+ if( isSelected() ) {
+ Platform.runLater( () -> 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() {
+ final Path filePath = getPath();
+
+ return (filePath == null)
+ ? Messages.get( "FileEditor.untitled" )
+ : filePath.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 (filePath == null)
+ ? null
+ : new Tooltip( 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 (*must* 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();
+
+ // Clear undo history after first load.
+ undoManager.forgetHistory();
+
+ // Bind the editor undo manager to the properties.
+ modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
+ canUndo.bind( undoManager.undoAvailableProperty() );
+ canRedo.bind( undoManager.redoAvailableProperty() );
+ }
+
+ /**
+ * 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 getEditorPane().getEditor().getCaretPosition();
+ }
+
+ /**
+ * 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 ? false : filePath.equals( check );
+ }
+
+ /**
+ * Reads the entire file contents from the path associated with this tab.
+ */
+ private void load() {
+ final Path filePath = getPath();
+
+ if( filePath != null ) {
+ try {
+ getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
+ } catch( Exception ex ) {
+ alert(
+ "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
+ );
+ }
+ }
+ }
+
+ /**
+ * Saves the entire file contents from the path associated with this tab.
+ *
+ * @return true The file has been saved.
+ */
+ public boolean save() {
+ try {
+ Files.write( getPath(), asBytes( getEditorPane().getText() ) );
+ getEditorPane().getUndoManager().mark();
+ return true;
+ } catch( 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
+ */
+ private boolean alert(
+ final String titleKey, final String messageKey, final Exception e ) {
+ final AlertService service = getAlertService();
+
+ final AlertMessage message = service.createAlertMessage(
+ Messages.get( titleKey ),
+ Messages.get( messageKey ),
+ getPath(),
+ e.getMessage()
+ );
+
+ service.createAlertError( message ).showAndWait();
+ return false;
+ }
+
+ /**
+ * 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 UniversalDetector detector = new UniversalDetector( null );
+ detector.handleData( bytes, 0, bytes.length );
+ detector.dataEnd();
+
+ final String charset = detector.getDetectedCharset();
+ final Charset charEncoding = charset == null
+ ? Charset.defaultCharset()
+ : Charset.forName( charset.toUpperCase( ENGLISH ) );
+
+ detector.reset();
+
+ return charEncoding;
+ }
+
+ /**
+ * 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() );
+ }
+
+ Path getPath() {
+ return this.path;
+ }
+
+ void setPath( final Path path ) {
+ this.path = path;
+ }
+
+ public boolean isModified() {
+ return this.modified.get();
+ }
+
+ ReadOnlyBooleanProperty modifiedProperty() {
+ return this.modified.getReadOnlyProperty();
+ }
+
+ BooleanProperty canUndoProperty() {
+ return this.canUndo;
+ }
+
+ BooleanProperty canRedoProperty() {
+ return this.canRedo;
+ }
+
+ private UndoManager getUndoManager() {
+ return getEditorPane().getUndoManager();
+ }
+
+ /**
+ * Forwards the request to the editor pane.
+ *
+ * @param <T> The type of event listener to add.
+ * @param <U> The type of consumer to add.
+ * @param event The event that should trigger updates to the listener.
+ * @param consumer The listener to receive update events.
+ */
+ public <T extends Event, U extends T> void addEventListener(
+ final EventPattern<? super T, ? extends U> event,
+ final Consumer<? super U> consumer ) {
+ getEditorPane().addEventListener( event, consumer );
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for keyboard events.
+ *
+ * @param map The new input map to replace the existing keyboard listener.
+ */
+ public void addEventListener( final InputMap<InputEvent> map ) {
+ getEditorPane().addEventListener( map );
+ }
+
+ /**
+ * Forwards to the editor pane's listeners for keyboard events.
+ *
+ * @param map The existing input map to remove from the keyboard listeners.
+ */
+ public void removeEventListener( final InputMap<InputEvent> map ) {
+ getEditorPane().removeEventListener( map );
+ }
+
+ /**
+ * Returns the editor pane, or creates one if it doesn't yet exist.
+ *
+ * @return The editor pane, never null.
+ */
+ protected EditorPane getEditorPane() {
+ if( this.editorPane == null ) {
+ this.editorPane = new MarkdownEditorPane();
+ }
+
+ return this.editorPane;
+ }
+
+ private AlertService getAlertService() {
+ return this.alertService;
+ }
+
+ private Options getOptions() {
+ return this.options;
}
src/main/java/com/scrivenvar/FileEditorTabPane.java
* @author Karl Tauber and White Magic Software, Ltd.
*/
-public class FileEditorTabPane extends TabPane implements ChangeListener<Tab> {
-
- private final static String FILTER_PREFIX = "Dialog.file.choose.filter";
-
- private final Options options = Services.load( Options.class );
- private final Settings settings = Services.load( Settings.class );
- private final AlertService alertService = Services.load( AlertService.class );
-
- private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
- private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
-
- public FileEditorTabPane() {
- final ObservableList<Tab> tabs = getTabs();
-
- setFocusTraversable( false );
- setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
-
- // Observe the tab so that when a new tab is opened or selected,
- // a notification is kicked off.
- getSelectionModel().selectedItemProperty().addListener( this );
-
- // update anyFileEditorModified property
- final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
- for( final Tab tab : tabs ) {
- if( ((FileEditorTab)tab.getUserData()).isModified() ) {
- this.anyFileEditorModified.set( true );
- break;
- }
- }
- };
-
- tabs.addListener( (ListChangeListener<Tab>)change -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- change.getAddedSubList().stream().forEach( (tab) -> {
- ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener );
- } );
- } else if( change.wasRemoved() ) {
- change.getRemoved().stream().forEach( (tab) -> {
- ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener );
- } );
- }
- }
-
- // Changes in the tabs may also change anyFileEditorModified property
- // (e.g. closed modified file)
- modifiedListener.changed( null, null, null );
- } );
- }
-
- public <T extends Event, U extends T> void addEventListener(
- final EventPattern<? super T, ? extends U> event,
- final Consumer<? super U> consumer ) {
- getActiveFileEditor().addEventListener( event, consumer );
- }
-
- /**
- * Delegates to the active file editor pane, and, ultimately, to its text
- * area.
- *
- * @param map The map of methods to events.
- */
- public void addEventListener( final InputMap<InputEvent> map ) {
- getActiveFileEditor().addEventListener( map );
- }
-
- public void removeEventListener( final InputMap<InputEvent> map ) {
- getActiveFileEditor().removeEventListener( map );
- }
-
- @Override
- public void changed(
- final ObservableValue<? extends Tab> observable,
- final Tab oldTab,
- final Tab newTab ) {
-
- if( newTab != null ) {
- this.activeFileEditor.set( (FileEditorTab)newTab.getUserData() );
- }
- }
-
- Node getNode() {
- return this;
- }
-
- /**
- * Allows clients to manipulate the editor content directly.
- *
- * @return The text area for the active file editor.
- */
- public StyledTextArea getEditor() {
- return getActiveFileEditor().getEditorPane().getEditor();
- }
-
- public FileEditorTab getActiveFileEditor() {
- return this.activeFileEditor.get();
- }
-
- ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
- return this.activeFileEditor.getReadOnlyProperty();
- }
-
- ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
- return this.anyFileEditorModified.getReadOnlyProperty();
- }
-
- private FileEditorTab createFileEditor( final Path path ) {
- final FileEditorTab tab = new FileEditorTab( path );
-
- tab.setOnCloseRequest( e -> {
- if( !canCloseEditor( tab ) ) {
- e.consume();
- }
- } );
-
- return tab;
- }
-
- /**
- * Called when the user selects New from the File menu.
- *
- * @return The newly added tab.
- */
- FileEditorTab newEditor() {
- final FileEditorTab tab = createFileEditor( null );
-
- getTabs().add( tab );
- getSelectionModel().select( tab );
- return tab;
- }
-
- List<FileEditorTab> openFileDialog() {
- final FileChooser dialog
- = createFileChooser( get( "Dialog.file.choose.open.title" ) );
- final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
-
- return (files != null && !files.isEmpty())
- ? openFiles( files )
- : new ArrayList<>();
- }
-
- /**
- * 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.
- *
- * @return A list of files that can be opened in text editors.
- */
- private List<FileEditorTab> openFiles( final List<File> files ) {
- final List<FileEditorTab> openedEditors = new ArrayList<>();
-
- final FileTypePredicate predicate
- = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() );
-
- // The user might have opened muliple 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 );
- editors.removeAll( definitions );
-
- // If there are any editor-friendly files opened (e.g,. Markdown, XML), then
- // open them up in new tabs.
- if( editors.size() > 0 ) {
- saveLastDirectory( editors.get( 0 ) );
- openedEditors.addAll( openEditors( editors, 0 ) );
- }
-
- if( definitions.size() > 0 ) {
- openDefinition( definitions.get( 0 ) );
- }
-
- return openedEditors;
- }
-
- private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) {
- final int fileTally = files.size();
- final List<FileEditorTab> editors = new ArrayList<>( fileTally );
- final List<Tab> tabs = getTabs();
-
- // Close single unmodified "Untitled" tab.
- if( tabs.size() == 1 ) {
- final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData());
-
- if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
- closeEditor( fileEditor, false );
- }
- }
-
- for( int i = 0; i < fileTally; i++ ) {
- final Path path = files.get( i ).toPath();
-
- // Check whether file is already opened.
- FileEditorTab fileEditor = findEditor( path );
-
- if( fileEditor == null ) {
- fileEditor = createFileEditor( path );
- getTabs().add( fileEditor );
- editors.add( fileEditor );
- }
-
- // Select first file.
- if( i == activeIndex ) {
- getSelectionModel().select( fileEditor );
- }
- }
-
- return editors;
- }
-
- /**
- * 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 ) {
- System.out.println( "open definition file: " + definition.toString() );
- }
-
- boolean saveEditor( final FileEditorTab fileEditor ) {
- if( fileEditor == null || !fileEditor.isModified() ) {
- return true;
- }
-
- if( fileEditor.getPath() == null ) {
- getSelectionModel().select( fileEditor );
-
- final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
- final File file = fileChooser.showSaveDialog( getWindow() );
- if( file == null ) {
- return false;
- }
-
- saveLastDirectory( file );
- fileEditor.setPath( file.toPath() );
- }
-
- return fileEditor.save();
- }
-
- boolean saveAllEditors() {
- boolean success = true;
-
- for( FileEditorTab fileEditor : getAllEditors() ) {
- if( !saveEditor( fileEditor ) ) {
- success = false;
- }
- }
-
- return success;
- }
-
- boolean canCloseEditor( final FileEditorTab tab ) {
- if( !tab.isModified() ) {
- return true;
- }
-
- final AlertMessage message = getAlertService().createAlertMessage(
- Messages.get( "Alert.file.close.title" ),
- Messages.get( "Alert.file.close.text" ),
- tab.getText()
- );
-
- final Alert alert = getAlertService().createAlertConfirmation( message );
- final ButtonType response = alert.showAndWait().get();
-
- return response == YES ? saveEditor( tab ) : response == NO;
- }
-
- private AlertService getAlertService() {
- return this.alertService;
- }
-
- boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
- if( fileEditor == null ) {
- return true;
- }
-
- final Tab tab = fileEditor;
-
- 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 FileEditorTab[] allEditors = new FileEditorTab[ tabs.size() ];
- final int length = tabs.size();
-
- for( int i = 0; i < length; i++ ) {
- allEditors[ i ] = (FileEditorTab)tabs.get( i ).getUserData();
- }
-
- 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;
-
- System.out.println( "path = " + path );
- System.out.println( "fileEditor = " + fileEditor.isPath( path ) );
-
- 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 = getState().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( "markdown" ) );
- list.add( createExtensionFilter( "definition" ) );
- list.add( createExtensionFilter( "xml" ) );
- list.add( createExtensionFilter( "all" ) );
- return list;
- }
-
- private ExtensionFilter createExtensionFilter( final String filetype ) {
- final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype );
- final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype );
-
- return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
- }
-
- private List<String> getExtensions( final String key ) {
- return getStringSettingList( key );
- }
-
- private List<String> getStringSettingList( String key ) {
- return getStringSettingList( key, null );
- }
-
- private List<String> getStringSettingList( String key, List<String> values ) {
- return getSettings().getStringSettingList( key, values );
- }
-
- private void saveLastDirectory( final File file ) {
- getState().put( "lastDirectory", file.getParent() );
- }
-
- public void restorePreferences() {
- int activeIndex = 0;
-
- final Preferences preferences = getState();
- final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
- final String activeFileName = preferences.get( "activeFile", null );
-
- final ArrayList<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();
- return;
- }
-
- 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;
-
- if( fileEditor.getPath() != null ) {
- fileNames.add( fileEditor.getPath().toString() );
- }
- }
-
- final Preferences preferences = getState();
- Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
-
- final FileEditorTab activeEditor = getActiveFileEditor();
-
- if( activeEditor != null && activeEditor.getPath() != null ) {
- preferences.put( "activeFile", activeEditor.getPath().toString() );
- } else {
- preferences.remove( "activeFile" );
- }
- }
-
- private Settings getSettings() {
- return this.settings;
- }
-
- protected Options getOptions() {
- return this.options;
- }
-
- private Window getWindow() {
- return getScene().getWindow();
- }
-
+public final class FileEditorTabPane extends TabPane {
+
+ private final static String FILTER_PREFIX = "Dialog.file.choose.filter";
+
+ private final Options options = Services.load( Options.class );
+ private final Settings settings = Services.load( Settings.class );
+ private final AlertService alertService = Services.load( AlertService.class );
+
+ private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
+ private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
+
+ public FileEditorTabPane() {
+ final ObservableList<Tab> tabs = getTabs();
+
+ setFocusTraversable( false );
+ setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
+
+ addTabChangeListener( (ObservableValue<? extends Tab> tabPane,
+ final Tab oldTab, final Tab newTab) -> {
+ if( newTab != null ) {
+ activeFileEditor.set( (FileEditorTab)newTab.getUserData() );
+ }
+ } );
+
+ final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
+ for( final Tab tab : tabs ) {
+ if( ((FileEditorTab)tab.getUserData()).isModified() ) {
+ this.anyFileEditorModified.set( true );
+ break;
+ }
+ }
+ };
+
+ tabs.addListener( (ListChangeListener<Tab>)change -> {
+ while( change.next() ) {
+ if( change.wasAdded() ) {
+ change.getAddedSubList().stream().forEach( (tab) -> {
+ ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener );
+ } );
+ } else if( change.wasRemoved() ) {
+ change.getRemoved().stream().forEach( (tab) -> {
+ ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener );
+ } );
+ }
+ }
+
+ // Changes in the tabs may also change anyFileEditorModified property
+ // (e.g. closed modified file)
+ modifiedListener.changed( null, null, null );
+ } );
+ }
+
+ public <T extends Event, U extends T> void addEventListener(
+ final EventPattern<? super T, ? extends U> event,
+ final Consumer<? super U> consumer ) {
+ getActiveFileEditor().addEventListener( event, consumer );
+ }
+
+ /**
+ * Delegates to the active file editor pane, and, ultimately, to its text
+ * area.
+ *
+ * @param map The map of methods to events.
+ */
+ public void addEventListener( final InputMap<InputEvent> map ) {
+ getActiveFileEditor().addEventListener( map );
+ }
+
+ /**
+ * Remove a keyboard event listener from the active file editor.
+ *
+ * @param map The keyboard events to remove.
+ */
+ public void removeEventListener( final InputMap<InputEvent> map ) {
+ getActiveFileEditor().removeEventListener( map );
+ }
+
+ /**
+ * Allows observers to be notified when the current file editor tab changes.
+ *
+ * @param listener The listener to notify of tab change events.
+ */
+ public void addTabChangeListener( 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 );
+ }
+
+ Node getNode() {
+ return this;
+ }
+
+ /**
+ * Allows clients to manipulate the editor content directly.
+ *
+ * @return The text area for the active file editor.
+ */
+ public StyledTextArea getEditor() {
+ return getActiveFileEditor().getEditorPane().getEditor();
+ }
+
+ public FileEditorTab getActiveFileEditor() {
+ return this.activeFileEditor.get();
+ }
+
+ ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
+ return this.activeFileEditor.getReadOnlyProperty();
+ }
+
+ ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
+ return this.anyFileEditorModified.getReadOnlyProperty();
+ }
+
+ private FileEditorTab createFileEditor( final Path path ) {
+ final FileEditorTab tab = new FileEditorTab( path );
+
+ tab.setOnCloseRequest( e -> {
+ if( !canCloseEditor( tab ) ) {
+ e.consume();
+ }
+ } );
+
+ return tab;
+ }
+
+ /**
+ * Called when the user selects New from the File menu.
+ *
+ * @return The newly added tab.
+ */
+ FileEditorTab newEditor() {
+ final FileEditorTab tab = createFileEditor( null );
+
+ getTabs().add( tab );
+ getSelectionModel().select( tab );
+ return tab;
+ }
+
+ List<FileEditorTab> openFileDialog() {
+ final FileChooser dialog
+ = createFileChooser( get( "Dialog.file.choose.open.title" ) );
+ final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
+
+ return (files != null && !files.isEmpty())
+ ? openFiles( files )
+ : new ArrayList<>();
+ }
+
+ /**
+ * 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.
+ *
+ * @return A list of files that can be opened in text editors.
+ */
+ private List<FileEditorTab> openFiles( final List<File> files ) {
+ final List<FileEditorTab> openedEditors = new ArrayList<>();
+
+ final FileTypePredicate predicate
+ = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() );
+
+ // The user might have opened muliple 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 );
+ editors.removeAll( definitions );
+
+ // If there are any editor-friendly files opened (e.g,. Markdown, XML), then
+ // open them up in new tabs.
+ if( editors.size() > 0 ) {
+ saveLastDirectory( editors.get( 0 ) );
+ openedEditors.addAll( openEditors( editors, 0 ) );
+ }
+
+ if( definitions.size() > 0 ) {
+ openDefinition( definitions.get( 0 ) );
+ }
+
+ return openedEditors;
+ }
+
+ private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) {
+ final int fileTally = files.size();
+ final List<FileEditorTab> editors = new ArrayList<>( fileTally );
+ final List<Tab> tabs = getTabs();
+
+ // Close single unmodified "Untitled" tab.
+ if( tabs.size() == 1 ) {
+ final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData());
+
+ if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
+ closeEditor( fileEditor, false );
+ }
+ }
+
+ for( int i = 0; i < fileTally; i++ ) {
+ final Path path = files.get( i ).toPath();
+
+ // Check whether file is already opened.
+ FileEditorTab fileEditor = findEditor( path );
+
+ if( fileEditor == null ) {
+ fileEditor = createFileEditor( path );
+ getTabs().add( fileEditor );
+ editors.add( fileEditor );
+ }
+
+ // Select first file.
+ if( i == activeIndex ) {
+ getSelectionModel().select( fileEditor );
+ }
+ }
+
+ return editors;
+ }
+
+ /**
+ * 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 ) {
+ System.out.println( "open definition file: " + definition.toString() );
+ }
+
+ boolean saveEditor( final FileEditorTab fileEditor ) {
+ if( fileEditor == null || !fileEditor.isModified() ) {
+ return true;
+ }
+
+ if( fileEditor.getPath() == null ) {
+ getSelectionModel().select( fileEditor );
+
+ final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
+ final File file = fileChooser.showSaveDialog( getWindow() );
+ if( file == null ) {
+ return false;
+ }
+
+ saveLastDirectory( file );
+ fileEditor.setPath( file.toPath() );
+ }
+
+ return fileEditor.save();
+ }
+
+ boolean saveAllEditors() {
+ boolean success = true;
+
+ for( FileEditorTab fileEditor : getAllEditors() ) {
+ if( !saveEditor( fileEditor ) ) {
+ success = false;
+ }
+ }
+
+ return success;
+ }
+
+ boolean canCloseEditor( final FileEditorTab tab ) {
+ if( !tab.isModified() ) {
+ return true;
+ }
+
+ final AlertMessage message = getAlertService().createAlertMessage(
+ Messages.get( "Alert.file.close.title" ),
+ Messages.get( "Alert.file.close.text" ),
+ tab.getText()
+ );
+
+ final Alert alert = getAlertService().createAlertConfirmation( message );
+ final ButtonType response = alert.showAndWait().get();
+
+ return response == YES ? saveEditor( tab ) : response == NO;
+ }
+
+ private AlertService getAlertService() {
+ return this.alertService;
+ }
+
+ boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
+ if( fileEditor == null ) {
+ return true;
+ }
+
+ final Tab tab = fileEditor;
+
+ 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 ).getUserData();
+ }
+
+ 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 = getState().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( "markdown" ) );
+ list.add( createExtensionFilter( "definition" ) );
+ list.add( createExtensionFilter( "xml" ) );
+ list.add( createExtensionFilter( "all" ) );
+ return list;
+ }
+
+ private ExtensionFilter createExtensionFilter( final String filetype ) {
+ final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype );
+ final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype );
+
+ return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
+ }
+
+ private List<String> getExtensions( final String key ) {
+ return getStringSettingList( key );
+ }
+
+ private List<String> getStringSettingList( String key ) {
+ return getStringSettingList( key, null );
+ }
+
+ private List<String> getStringSettingList( String key, List<String> values ) {
+ return getSettings().getStringSettingList( key, values );
+ }
+
+ private void saveLastDirectory( final File file ) {
+ getState().put( "lastDirectory", file.getParent() );
+ }
+
+ public void restorePreferences() {
+ int activeIndex = 0;
+
+ final Preferences preferences = getState();
+ final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
+ final String activeFileName = preferences.get( "activeFile", null );
+
+ final ArrayList<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();
+ return;
+ }
+
+ 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;
+
+ if( fileEditor.getPath() != null ) {
+ fileNames.add( fileEditor.getPath().toString() );
+ }
+ }
+
+ final Preferences preferences = getState();
+ Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
+
+ final FileEditorTab activeEditor = getActiveFileEditor();
+
+ if( activeEditor != null && activeEditor.getPath() != null ) {
+ preferences.put( "activeFile", activeEditor.getPath().toString() );
+ } else {
+ preferences.remove( "activeFile" );
+ }
+ }
+
+ private Settings getSettings() {
+ return this.settings;
+ }
+
+ protected Options getOptions() {
+ return this.options;
+ }
+
+ private Window getWindow() {
+ return getScene().getWindow();
+ }
+
protected Preferences getState() {
return getOptions().getState();
src/main/java/com/scrivenvar/MainWindow.java
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
-import com.scrivenvar.yaml.YamlParser;
-import com.scrivenvar.yaml.YamlTreeAdapter;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
-import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.prefs.Preferences;
-import javafx.beans.binding.Bindings;
-import javafx.beans.binding.BooleanBinding;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ObservableBooleanValue;
-import javafx.beans.value.ObservableValue;
-import javafx.collections.ListChangeListener.Change;
-import javafx.collections.ObservableList;
-import javafx.event.Event;
-import javafx.scene.Node;
-import javafx.scene.Scene;
-import javafx.scene.control.Alert;
-import javafx.scene.control.Alert.AlertType;
-import javafx.scene.control.Menu;
-import javafx.scene.control.MenuBar;
-import javafx.scene.control.SplitPane;
-import javafx.scene.control.Tab;
-import javafx.scene.control.ToolBar;
-import javafx.scene.control.TreeView;
-import javafx.scene.image.Image;
-import javafx.scene.image.ImageView;
-import static javafx.scene.input.KeyCode.ESCAPE;
-import javafx.scene.input.KeyEvent;
-import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
-import static javafx.scene.input.KeyEvent.KEY_PRESSED;
-import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.VBox;
-import javafx.stage.Window;
-import javafx.stage.WindowEvent;
-import org.fxmisc.richtext.StyleClassedTextArea;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.Messages.get;
-import static com.scrivenvar.Messages.get;
-
-/**
- * Main window containing a tab pane in the center for file editors.
- *
- * @author Karl Tauber and White Magic Software, Ltd.
- */
-public class MainWindow {
-
- private final Options options = Services.load( Options.class );
-
- private Scene scene;
-
- private TreeView<String> treeView;
- private FileEditorTabPane fileEditorPane;
- private DefinitionPane definitionPane;
-
- private VariableNameInjector variableNameInjector;
-
- private YamlTreeAdapter yamlTreeAdapter;
- private YamlParser yamlParser;
-
- private MenuBar menuBar;
-
- public MainWindow() {
- initLayout();
- initVariableNameInjector();
- }
-
- private void initLayout() {
- final SplitPane splitPane = new SplitPane(
- getDefinitionPane().getNode(),
- getFileEditorPane().getNode() );
-
- splitPane.setDividerPositions(
- getFloat( K_PANE_SPLIT_DEFINITION, .05f ),
- getFloat( K_PANE_SPLIT_EDITOR, .95f ) );
-
- // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
- final BorderPane borderPane = new BorderPane();
- borderPane.setPrefSize( 1024, 800 );
- borderPane.setTop( createMenuBar() );
- borderPane.setCenter( splitPane );
-
- final Scene appScene = new Scene( borderPane );
- setScene( appScene );
- appScene.getStylesheets().add( Constants.STYLESHEET_PREVIEW );
- appScene.windowProperty().addListener(
- (observable, oldWindow, newWindow) -> {
- newWindow.setOnCloseRequest( e -> {
- if( !getFileEditorPane().closeAllEditors() ) {
- e.consume();
- }
- } );
-
- // Workaround JavaFX bug: deselect menubar if window loses focus.
- newWindow.focusedProperty().addListener(
- (obs, oldFocused, newFocused) -> {
- if( !newFocused ) {
- // Send an ESC key event to the menubar
- this.menuBar.fireEvent(
- new KeyEvent(
- KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
- false, false, false, false ) );
- }
- } );
- } );
- }
-
- private void initVariableNameInjector() {
- setVariableNameInjector( new VariableNameInjector(
- getFileEditorPane(),
- getDefinitionPane() )
- );
- }
-
- private Window getWindow() {
- return getScene().getWindow();
- }
-
- public Scene getScene() {
- return this.scene;
- }
-
- private void setScene( Scene scene ) {
- this.scene = scene;
- }
-
- /**
- * 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.bind( func.apply( newFileEditor ) );
- } else {
- b.set( false );
- }
- } );
-
- return b;
- }
-
- //---- File actions -------------------------------------------------------
- private void fileNew() {
- getFileEditorPane().newEditor();
- }
-
- private void fileOpen() {
- getFileEditorPane().openFileDialog();
- }
-
- private void fileClose() {
- getFileEditorPane().closeEditor( getActiveFileEditor(), true );
- }
-
- private void fileCloseAll() {
- getFileEditorPane().closeAllEditors();
- }
-
- private void fileSave() {
- getFileEditorPane().saveEditor( getActiveFileEditor() );
- }
-
- private void fileSaveAll() {
- getFileEditorPane().saveAllEditors();
- }
-
- private void fileExit() {
- final Window window = getWindow();
- Event.fireEvent( window,
- new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
- }
-
- //---- Help actions -------------------------------------------------------
- private void helpAbout() {
- Alert alert = new Alert( AlertType.INFORMATION );
- alert.setTitle( Messages.get( "Dialog.about.title" ) );
- alert.setHeaderText( Messages.get( "Dialog.about.header" ) );
- alert.setContentText( Messages.get( "Dialog.about.content" ) );
- alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) );
- alert.initOwner( getWindow() );
-
- alert.showAndWait();
- }
-
- private FileEditorTabPane getFileEditorPane() {
- if( this.fileEditorPane == null ) {
- this.fileEditorPane = createFileEditorPane();
- }
-
- return this.fileEditorPane;
- }
-
- private FileEditorTabPane createFileEditorPane() {
- // Create an editor pane to hold file editor tabs.
- final FileEditorTabPane editorPane = new FileEditorTabPane();
-
- // Make sure the text processor kicks off when new files are opened.
- final ObservableList<Tab> tabs = editorPane.getTabs();
-
- tabs.addListener( (Change<? extends Tab> change) -> {
- while( change.next() ) {
- if( change.wasAdded() ) {
- // Multiple tabs can be added simultaneously.
- for( final Tab tab : change.getAddedSubList() ) {
- addListener( (FileEditorTab)tab );
- }
- }
- }
- } );
-
- // After the processors are in place, restorePreferences the previously closed
- // tabs. Adding them will trigger the change event, above.
- editorPane.restorePreferences();
-
- return editorPane;
- }
-
- private MarkdownEditorPane getActiveEditor() {
- return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
- }
-
- private FileEditorTab getActiveFileEditor() {
- return getFileEditorPane().getActiveFileEditor();
- }
-
- /**
- * Listens for changes to tabs and their text editors.
- *
- * @see https://github.com/DaveJarvis/scrivenvar/issues/17
- * @see https://github.com/DaveJarvis/scrivenvar/issues/18
- *
- * @param tab The file editor tab that contains a text editor.
- */
- private void addListener( FileEditorTab tab ) {
- final HTMLPreviewPane previewPane = tab.getPreviewPane();
- final EditorPane editorPanel = tab.getEditorPane();
- final StyleClassedTextArea editor = editorPanel.getEditor();
-
- // TODO: Use a factory based on the filename extension. The default
- // extension will be for a markdown file (e.g., on file new).
- final Processor<String> hpp = new HTMLPreviewProcessor( previewPane );
- final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
- final Processor<String> mp = new MarkdownProcessor( mcrp );
- final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, editor );
- final Processor<String> vnp = new VariableProcessor( mcip, getResolvedMap() );
- final TextChangeProcessor tp = new TextChangeProcessor( vnp );
-
- editorPanel.addChangeListener( tp );
- editorPanel.addCaretParagraphListener(
- (final ObservableValue<? extends Integer> observable,
- final Integer oldValue, final Integer newValue) -> {
-
- // Kick off the processing chain at the variable processor when the
- // cursor changes paragraphs. This might cause some slight duplication
- // when the Enter key is pressed.
- vnp.processChain( editor.getText() );
- } );
- }
-
- protected DefinitionPane createDefinitionPane() {
- return new DefinitionPane( getTreeView() );
- }
-
- private DefinitionPane getDefinitionPane() {
- if( this.definitionPane == null ) {
- this.definitionPane = createDefinitionPane();
- }
-
- return this.definitionPane;
- }
-
- public MenuBar getMenuBar() {
- return menuBar;
- }
-
- public void setMenuBar( MenuBar menuBar ) {
- this.menuBar = menuBar;
- }
-
- public VariableNameInjector getVariableNameInjector() {
- return this.variableNameInjector;
- }
-
- public void setVariableNameInjector( VariableNameInjector variableNameInjector ) {
- this.variableNameInjector = variableNameInjector;
- }
-
- private float getFloat( final String key, final float defaultValue ) {
- return getPreferences().getFloat( key, defaultValue );
- }
-
- private Preferences getPreferences() {
- return getOptions().getState();
- }
-
- private Options getOptions() {
- return this.options;
- }
-
- private synchronized TreeView<String> getTreeView() throws RuntimeException {
- if( this.treeView == null ) {
- try {
- this.treeView = createTreeView();
- } catch( IOException ex ) {
-
- // TODO: Pop an error message.
- throw new RuntimeException( ex );
- }
- }
-
- return this.treeView;
- }
-
- private InputStream asStream( final String resource ) {
- return getClass().getResourceAsStream( resource );
- }
-
- private TreeView<String> createTreeView() throws IOException {
- // TODO: Associate variable file with path to current file.
- return getYamlTreeAdapter().adapt(
- asStream( "/com/scrivenvar/variables.yaml" ),
- get( "Pane.defintion.node.root.title" )
- );
- }
-
- private Map<String, String> getResolvedMap() {
- return getYamlParser().createResolvedMap();
- }
-
- private YamlTreeAdapter getYamlTreeAdapter() {
- if( this.yamlTreeAdapter == null ) {
- setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
- }
-
- return this.yamlTreeAdapter;
- }
-
- private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
- this.yamlTreeAdapter = yamlTreeAdapter;
- }
-
- private YamlParser getYamlParser() {
- if( this.yamlParser == null ) {
- setYamlParser( new YamlParser() );
- }
-
- return this.yamlParser;
- }
-
- private void setYamlParser( final YamlParser yamlParser ) {
- this.yamlParser = yamlParser;
- }
-
- private Node createMenuBar() {
- final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
-
- // File actions
- Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
- Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
- Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
- Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
- Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
- createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
- Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
- Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
- Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
-
- // Edit actions
- Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
- e -> getActiveEditor().undo(),
- createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
- Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
- e -> getActiveEditor().redo(),
- createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
-
- // Insert actions
- Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
- e -> getActiveEditor().surroundSelection( "**", "**" ),
- activeFileEditorIsNull );
- Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
- e -> getActiveEditor().surroundSelection( "*", "*" ),
- activeFileEditorIsNull );
- Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
- e -> getActiveEditor().surroundSelection( "~~", "~~" ),
- activeFileEditorIsNull );
- Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
- e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
- activeFileEditorIsNull );
- Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
- e -> getActiveEditor().surroundSelection( "`", "`" ),
- activeFileEditorIsNull );
- Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
- e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ),
- activeFileEditorIsNull );
-
- Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
- e -> getActiveEditor().insertLink(),
- activeFileEditorIsNull );
- Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
- e -> getActiveEditor().insertImage(),
- activeFileEditorIsNull );
-
- final Action[] headers = new Action[ 6 ];
-
- // Insert header actions (H1 ... H6)
- for( int i = 1; i <= 6; i++ ) {
- final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
- final String markup = String.format( "\n\n%s ", hashes );
- final String text = Messages.get( "Main.menu.insert.header_" + i );
- final String accelerator = "Shortcut+" + i;
- final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" );
-
- headers[ i - 1 ] = new Action( text, accelerator, HEADER,
- e -> getActiveEditor().surroundSelection( markup, "", prompt ),
- activeFileEditorIsNull );
- }
-
- Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
- e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
- activeFileEditorIsNull );
- Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
- e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
- activeFileEditorIsNull );
- Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
- e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
- activeFileEditorIsNull );
-
- // Help actions
- Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
-
- //---- MenuBar ----
- Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ),
- fileNewAction,
- fileOpenAction,
- null,
- fileCloseAction,
- fileCloseAllAction,
- null,
- fileSaveAction,
- fileSaveAllAction,
- null,
- fileExitAction );
-
- Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ),
- editUndoAction,
- editRedoAction );
-
- Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ),
- insertBoldAction,
- insertItalicAction,
- insertStrikethroughAction,
- insertBlockquoteAction,
- insertCodeAction,
- insertFencedCodeBlockAction,
- null,
- insertLinkAction,
- insertImageAction,
- null,
- headers[ 0 ],
- headers[ 1 ],
- headers[ 2 ],
- headers[ 3 ],
- headers[ 4 ],
- headers[ 5 ],
- null,
- insertUnorderedListAction,
- insertOrderedListAction,
- insertHorizontalRuleAction );
-
- Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ),
- helpAboutAction );
-
- menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
-
- //---- ToolBar ----
- ToolBar toolBar = ActionUtils.createToolBar(
- fileNewAction,
- fileOpenAction,
- fileSaveAction,
- null,
- editUndoAction,
- editRedoAction,
- null,
- insertBoldAction,
- insertItalicAction,
- insertBlockquoteAction,
- insertCodeAction,
- insertFencedCodeBlockAction,
- null,
- insertLinkAction,
- insertImageAction,
- null,
- headers[ 0 ],
- null,
- insertUnorderedListAction,
- insertOrderedListAction );
-
- return new VBox( menuBar, toolBar );
- }
+import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
+import com.scrivenvar.yaml.YamlParser;
+import com.scrivenvar.yaml.YamlTreeAdapter;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
+import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.prefs.Preferences;
+import javafx.beans.binding.Bindings;
+import javafx.beans.binding.BooleanBinding;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ObservableBooleanValue;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ListChangeListener.Change;
+import javafx.collections.ObservableList;
+import javafx.event.Event;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Alert.AlertType;
+import javafx.scene.control.Menu;
+import javafx.scene.control.MenuBar;
+import javafx.scene.control.SplitPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.ToolBar;
+import javafx.scene.control.TreeView;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import static javafx.scene.input.KeyCode.ESCAPE;
+import javafx.scene.input.KeyEvent;
+import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
+import static javafx.scene.input.KeyEvent.KEY_PRESSED;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.VBox;
+import javafx.stage.Window;
+import javafx.stage.WindowEvent;
+import org.fxmisc.richtext.StyleClassedTextArea;
+
+/**
+ * Main window containing a tab pane in the center for file editors.
+ *
+ * @author Karl Tauber and White Magic Software, Ltd.
+ */
+public class MainWindow {
+
+ private final Options options = Services.load( Options.class );
+
+ private Scene scene;
+
+ private TreeView<String> treeView;
+ private DefinitionPane definitionPane;
+ private FileEditorTabPane fileEditorPane;
+ private HTMLPreviewPane previewPane;
+
+ private VariableNameInjector variableNameInjector;
+
+ private YamlTreeAdapter yamlTreeAdapter;
+ private YamlParser yamlParser;
+
+ private MenuBar menuBar;
+
+ public MainWindow() {
+ initLayout();
+ initTabsListener();
+ restorePreferences();
+ initEditorPaneListeners();
+ initVariableNameInjector();
+ }
+
+ private void initLayout() {
+ 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 ) );
+
+ // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
+ final BorderPane borderPane = new BorderPane();
+ borderPane.setPrefSize( 1024, 800 );
+ borderPane.setTop( createMenuBar() );
+ borderPane.setCenter( splitPane );
+
+ final Scene appScene = new Scene( borderPane );
+ setScene( appScene );
+ appScene.getStylesheets().add( Constants.STYLESHEET_PREVIEW );
+ appScene.windowProperty().addListener(
+ (observable, oldWindow, newWindow) -> {
+ newWindow.setOnCloseRequest( e -> {
+ if( !getFileEditorPane().closeAllEditors() ) {
+ e.consume();
+ }
+ } );
+
+ // Workaround JavaFX bug: deselect menubar if window loses focus.
+ newWindow.focusedProperty().addListener(
+ (obs, oldFocused, newFocused) -> {
+ if( !newFocused ) {
+ // Send an ESC key event to the menubar
+ this.menuBar.fireEvent(
+ new KeyEvent(
+ KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
+ false, false, false, false ) );
+ }
+ } );
+ } );
+ }
+
+ private void initVariableNameInjector() {
+ setVariableNameInjector( new VariableNameInjector(
+ getFileEditorPane(),
+ getDefinitionPane() )
+ );
+ }
+
+ private Window getWindow() {
+ return getScene().getWindow();
+ }
+
+ public Scene getScene() {
+ return this.scene;
+ }
+
+ private void setScene( Scene scene ) {
+ this.scene = scene;
+ }
+
+ /**
+ * 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.bind( func.apply( newFileEditor ) );
+ } else {
+ b.set( false );
+ }
+ } );
+
+ return b;
+ }
+
+ //---- File actions -------------------------------------------------------
+ private void fileNew() {
+ getFileEditorPane().newEditor();
+ }
+
+ private void fileOpen() {
+ getFileEditorPane().openFileDialog();
+ }
+
+ private void fileClose() {
+ getFileEditorPane().closeEditor( getActiveFileEditor(), true );
+ }
+
+ private void fileCloseAll() {
+ getFileEditorPane().closeAllEditors();
+ }
+
+ private void fileSave() {
+ getFileEditorPane().saveEditor( getActiveFileEditor() );
+ }
+
+ private void fileSaveAll() {
+ getFileEditorPane().saveAllEditors();
+ }
+
+ private void fileExit() {
+ final Window window = getWindow();
+ Event.fireEvent( window,
+ new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
+ }
+
+ //---- Help actions -------------------------------------------------------
+ private void helpAbout() {
+ Alert alert = new Alert( AlertType.INFORMATION );
+ alert.setTitle( Messages.get( "Dialog.about.title" ) );
+ alert.setHeaderText( Messages.get( "Dialog.about.header" ) );
+ alert.setContentText( Messages.get( "Dialog.about.content" ) );
+ alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) );
+ alert.initOwner( getWindow() );
+
+ alert.showAndWait();
+ }
+
+ private FileEditorTabPane getFileEditorPane() {
+ if( this.fileEditorPane == null ) {
+ this.fileEditorPane = createFileEditorPane();
+ }
+
+ return this.fileEditorPane;
+ }
+
+ /**
+ * Create an editor pane to hold file editor tabs.
+ *
+ * @return A new instance, never null.
+ */
+ private FileEditorTabPane createFileEditorPane() {
+ return new FileEditorTabPane();
+ }
+
+ /**
+ * Reloads the preferences from the previous load.
+ */
+ private void restorePreferences() {
+ getFileEditorPane().restorePreferences();
+ }
+
+ private void initTabsListener() {
+ 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;
+
+ refresh( tab );
+ }
+ }
+ }
+ } );
+ }
+
+ private void initEditorPaneListeners() {
+ final FileEditorTabPane editorPane = getFileEditorPane();
+
+ // Update the preview pane when moving the caret to a new paragraph.
+ editorPane.addTabChangeListener(
+ (ObservableValue<? extends Tab> tabPane,
+ final Tab oldTab, final Tab newTab) -> {
+
+ final FileEditorTab tab = (FileEditorTab)newTab;
+
+ if( tab != null ) {
+ getPreviewPane().setPath( tab.getPath() );
+ refresh( tab );
+ }
+ } );
+
+ editorPane.getEditor().textProperty().addListener( (ov, oldv, newv) -> {
+ refresh( getActiveFileEditor() );
+ } );
+ }
+
+ private void refresh( final FileEditorTab tab ) {
+ System.out.println( "REFRESH: " + tab.getPath().toAbsolutePath() );
+ }
+
+ private MarkdownEditorPane getActiveEditor() {
+ return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
+ }
+
+ private FileEditorTab getActiveFileEditor() {
+ return getFileEditorPane().getActiveFileEditor();
+ }
+
+ private Processor<String> createVariableProcessor( final FileEditorTab tab ) {
+ final HTMLPreviewPane previewPanel = getPreviewPane();
+ final EditorPane editorPanel = tab.getEditorPane();
+ final StyleClassedTextArea editor = editorPanel.getEditor();
+
+ // TODO: Use a factory based on the filename extension. The default
+ // extension will be for a markdown file (e.g., on file new).
+ final Processor<String> hpp = new HTMLPreviewProcessor( previewPanel );
+ final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
+ final Processor<String> mp = new MarkdownProcessor( mcrp );
+ final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, editor.caretPositionProperty() );
+ final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
+
+ return vp;
+ }
+
+ private TextChangeProcessor createTextChangeProcessor(
+ final Processor<String> link ) {
+ return new TextChangeProcessor( link );
+ }
+
+ /**
+ * Listens for changes to tabs and their text editors.
+ *
+ * @see https://github.com/DaveJarvis/scrivenvar/issues/17
+ * @see https://github.com/DaveJarvis/scrivenvar/issues/18
+ *
+ * @param tab The file editor tab that contains a text editor.
+ */
+ private void addListener( final FileEditorTab tab ) {
+ final Processor<String> vnp = createVariableProcessor( tab );
+ final TextChangeProcessor tcp = createTextChangeProcessor( vnp );
+
+ addCaretParagraphListener( tab, vnp );
+ }
+
+ /**
+ * When the caret changes paragraph, force re-rendering of the preview panel
+ * using the chain-of-command.
+ *
+ * @param tab Contains a text editor to monitor for caret position changes.
+ * @param vnp Called to re-process chain using the editor's content.
+ */
+ private void addCaretParagraphListener( final FileEditorTab tab, final Processor<String> vnp ) {
+ final EditorPane editorPanel = tab.getEditorPane();
+ final StyleClassedTextArea editor = editorPanel.getEditor();
+
+ editorPanel.addCaretParagraphListener(
+ (final ObservableValue<? extends Integer> observable,
+ final Integer oldValue, final Integer newValue) -> {
+
+ // Kick off the processing chain at the variable processor when the
+ // cursor changes paragraphs. This might cause some slight duplication
+ // when the Enter key is pressed.
+ vnp.processChain( editor.getText() );
+ } );
+ }
+
+ protected DefinitionPane createDefinitionPane() {
+ return new DefinitionPane( getTreeView() );
+ }
+
+ private DefinitionPane getDefinitionPane() {
+ if( this.definitionPane == null ) {
+ this.definitionPane = createDefinitionPane();
+ }
+
+ return this.definitionPane;
+ }
+
+ public MenuBar getMenuBar() {
+ return menuBar;
+ }
+
+ public void setMenuBar( MenuBar menuBar ) {
+ this.menuBar = menuBar;
+ }
+
+ public VariableNameInjector getVariableNameInjector() {
+ return this.variableNameInjector;
+ }
+
+ public void setVariableNameInjector( VariableNameInjector variableNameInjector ) {
+ this.variableNameInjector = variableNameInjector;
+ }
+
+ private float getFloat( final String key, final float defaultValue ) {
+ return getPreferences().getFloat( key, defaultValue );
+ }
+
+ private Preferences getPreferences() {
+ return getOptions().getState();
+ }
+
+ private Options getOptions() {
+ return this.options;
+ }
+
+ private synchronized TreeView<String> getTreeView() throws RuntimeException {
+ if( this.treeView == null ) {
+ try {
+ this.treeView = createTreeView();
+ } catch( IOException ex ) {
+
+ // TODO: Pop an error message.
+ throw new RuntimeException( ex );
+ }
+ }
+
+ return this.treeView;
+ }
+
+ private InputStream asStream( final String resource ) {
+ return getClass().getResourceAsStream( resource );
+ }
+
+ private TreeView<String> createTreeView() throws IOException {
+ // TODO: Associate variable file with path to current file.
+ return getYamlTreeAdapter().adapt(
+ asStream( "/com/scrivenvar/variables.yaml" ),
+ get( "Pane.defintion.node.root.title" )
+ );
+ }
+
+ private Map<String, String> getResolvedMap() {
+ return getYamlParser().createResolvedMap();
+ }
+
+ private YamlTreeAdapter getYamlTreeAdapter() {
+ if( this.yamlTreeAdapter == null ) {
+ setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
+ }
+
+ return this.yamlTreeAdapter;
+ }
+
+ private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
+ this.yamlTreeAdapter = yamlTreeAdapter;
+ }
+
+ private YamlParser getYamlParser() {
+ if( this.yamlParser == null ) {
+ setYamlParser( new YamlParser() );
+ }
+
+ return this.yamlParser;
+ }
+
+ private void setYamlParser( final YamlParser yamlParser ) {
+ this.yamlParser = yamlParser;
+ }
+
+ private Node createMenuBar() {
+ final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
+
+ // File actions
+ Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
+ Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
+ Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
+ Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
+ Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
+ createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
+ Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
+ Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
+ Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
+
+ // Edit actions
+ Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
+ e -> getActiveEditor().undo(),
+ createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
+ Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
+ e -> getActiveEditor().redo(),
+ createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
+
+ // Insert actions
+ Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
+ e -> getActiveEditor().surroundSelection( "**", "**" ),
+ activeFileEditorIsNull );
+ Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
+ e -> getActiveEditor().surroundSelection( "*", "*" ),
+ activeFileEditorIsNull );
+ Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
+ e -> getActiveEditor().surroundSelection( "~~", "~~" ),
+ activeFileEditorIsNull );
+ Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
+ e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
+ activeFileEditorIsNull );
+ Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
+ e -> getActiveEditor().surroundSelection( "`", "`" ),
+ activeFileEditorIsNull );
+ Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
+ e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ),
+ activeFileEditorIsNull );
+
+ Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
+ e -> getActiveEditor().insertLink(),
+ activeFileEditorIsNull );
+ Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
+ e -> getActiveEditor().insertImage(),
+ activeFileEditorIsNull );
+
+ final Action[] headers = new Action[ 6 ];
+
+ // Insert header actions (H1 ... H6)
+ for( int i = 1; i <= 6; i++ ) {
+ final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
+ final String markup = String.format( "\n\n%s ", hashes );
+ final String text = Messages.get( "Main.menu.insert.header_" + i );
+ final String accelerator = "Shortcut+" + i;
+ final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" );
+
+ headers[ i - 1 ] = new Action( text, accelerator, HEADER,
+ e -> getActiveEditor().surroundSelection( markup, "", prompt ),
+ activeFileEditorIsNull );
+ }
+
+ Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
+ e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
+ activeFileEditorIsNull );
+ Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
+ e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
+ activeFileEditorIsNull );
+ Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
+ e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
+ activeFileEditorIsNull );
+
+ // Help actions
+ Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
+
+ //---- MenuBar ----
+ Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ),
+ fileNewAction,
+ fileOpenAction,
+ null,
+ fileCloseAction,
+ fileCloseAllAction,
+ null,
+ fileSaveAction,
+ fileSaveAllAction,
+ null,
+ fileExitAction );
+
+ Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ),
+ editUndoAction,
+ editRedoAction );
+
+ Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ),
+ insertBoldAction,
+ insertItalicAction,
+ insertStrikethroughAction,
+ insertBlockquoteAction,
+ insertCodeAction,
+ insertFencedCodeBlockAction,
+ null,
+ insertLinkAction,
+ insertImageAction,
+ null,
+ headers[ 0 ],
+ headers[ 1 ],
+ headers[ 2 ],
+ headers[ 3 ],
+ headers[ 4 ],
+ headers[ 5 ],
+ null,
+ insertUnorderedListAction,
+ insertOrderedListAction,
+ insertHorizontalRuleAction );
+
+ Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ),
+ helpAboutAction );
+
+ menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
+
+ //---- ToolBar ----
+ ToolBar toolBar = ActionUtils.createToolBar(
+ fileNewAction,
+ fileOpenAction,
+ fileSaveAction,
+ null,
+ editUndoAction,
+ editRedoAction,
+ null,
+ insertBoldAction,
+ insertItalicAction,
+ insertBlockquoteAction,
+ insertCodeAction,
+ insertFencedCodeBlockAction,
+ null,
+ insertLinkAction,
+ insertImageAction,
+ null,
+ headers[ 0 ],
+ null,
+ insertUnorderedListAction,
+ insertOrderedListAction );
+
+ return new VBox( menuBar, toolBar );
+ }
+
+ private synchronized HTMLPreviewPane getPreviewPane() {
+ if( this.previewPane == null ) {
+ this.previewPane = new HTMLPreviewPane();
+ }
+
+ return this.previewPane;
+ }
+
}
src/main/java/com/scrivenvar/editor/EditorPane.java
* @param listener Receives paragraph change events.
*/
- public void addCaretParagraphListener( final ChangeListener<? super Integer> listener ) {
+ public void addCaretParagraphListener(
+ final ChangeListener<? super Integer> listener ) {
getEditor().currentParagraphProperty().addListener( listener );
}
src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
private final WebView webView = new WebView();
- private String html;
private Path path;
/**
* Creates a new preview pane that can scroll to the caret position within the
* document.
- *
- * @param path The base path for loading resources, such as images.
*/
- public HTMLPreviewPane( final Path path ) {
- setPath( path );
+ public HTMLPreviewPane() {
initListeners();
initTraversal();
}
- private void setPath( final Path path ) {
+ public void setPath( final Path path ) {
this.path = path;
}
+ /**
+ * Content to embed in a panel.
+ *
+ * @return The content to display to the user.
+ */
public Node getNode() {
return getWebView();
src/main/java/com/scrivenvar/processors/MarkdownCaretInsertionProcessor.java
import static com.scrivenvar.Constants.MD_CARET_POSITION;
import static java.lang.Character.isLetter;
-import org.fxmisc.richtext.model.TextEditingArea;
+import javafx.beans.value.ObservableValue;
/**
public class MarkdownCaretInsertionProcessor extends AbstractProcessor<String> {
- private TextEditingArea editor;
+ private final ObservableValue<Integer> caretPosition;
/**
* Constructs a processor capable of inserting a caret marker into Markdown.
*
* @param processor The next processor in the chain.
- * @param editor The editor that has a caret with a position in the text.
+ * @param position The caret's current position in the text, cannot be null.
*/
public MarkdownCaretInsertionProcessor(
- final Processor<String> processor, final TextEditingArea editor ) {
+ final Processor<String> processor, final ObservableValue<Integer> position ) {
super( processor );
- setEditor( editor );
+ this.caretPosition = position;
}
offset++;
}
+
+ // TODO: Ensure that the caret position is outside of an element,
+ // so that a caret inserted in the image doesn't corrupt it. Such as:
+ //
+ // ![Screenshot](images/scr|eenshot.png)
// Insert the caret position into the Markdown text, but don't interfere
*/
private int getCaretPosition() {
- return getEditor().getCaretPosition();
- }
-
- /**
- * Returns the editor that has a caret position.
- *
- * @return An editor with a caret position.
- */
- private TextEditingArea getEditor() {
- return this.editor;
- }
-
- private void setEditor( final TextEditingArea editor ) {
- this.editor = editor;
+ return this.caretPosition.getValue();
}
}
src/main/java/com/scrivenvar/util/StageState.java
public static final String K_PANE_SPLIT_DEFINITION = "pane.split.definition";
public static final String K_PANE_SPLIT_EDITOR = "pane.split.editor";
+ public static final String K_PANE_SPLIT_PREVIEW = "pane.split.preview";
private final Stage stage;
Delta1484 lines added, 1428 lines removed, 56-line increase