| Author | DaveJarvis <email> |
|---|---|
| Date | 2020-06-22 18:39:52 GMT-0700 |
| Commit | a62a9ebc682b3efa1560f68e07ac7f646331f6e0 |
| Parent | 2c933a4 |
| Delta | 2285 lines added, 2282 lines removed, 3-line increase |
| /* | ||
| - * 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; | ||
| } | ||
| 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; | ||
| } |
| 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 ); | ||
| } | ||
| 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(); | ||
| } | ||
| 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." ); | ||
| } | ||
| } |
| +/* | ||
| + * 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; | ||
| + } | ||
| +} | ||