| Author | djarvis <email> |
|---|---|
| Date | 2016-12-05 08:39:52 GMT-0800 |
| Commit | c722d7e9a69b0af24449975d0f480129f1af3937 |
| Parent | fd95319 |
| Delta | 1851 lines added, 1380 lines removed, 471-line increase |
| /** | ||
| * All services inherit from this one. | ||
| - * | ||
| + * | ||
| * @author White Magic Software, Ltd. | ||
| */ | ||
| public interface Service { | ||
| - | ||
| } | ||
| import com.vladsch.flexmark.ast.Node; | ||
| import java.nio.file.Path; | ||
| -import java.util.function.Consumer; | ||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
| import javafx.beans.InvalidationListener; | ||
| import javafx.beans.WeakInvalidationListener; | ||
| import javafx.beans.value.ChangeListener; | ||
| import javafx.beans.value.ObservableValue; | ||
| -import javafx.event.Event; | ||
| import javafx.scene.control.Dialog; | ||
| import javafx.scene.control.IndexRange; | ||
| -import javafx.scene.input.InputEvent; | ||
| import static javafx.scene.input.KeyCode.ENTER; | ||
| import javafx.scene.input.KeyEvent; | ||
| import javafx.stage.Window; | ||
| import org.fxmisc.richtext.StyleClassedTextArea; | ||
| -import org.fxmisc.wellbehaved.event.EventPattern; | ||
| import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | ||
| -import org.fxmisc.wellbehaved.event.InputMap; | ||
| -import static org.fxmisc.wellbehaved.event.InputMap.consume; | ||
| -import org.fxmisc.wellbehaved.event.Nodes; | ||
| /** | ||
| private static final Pattern AUTO_INDENT_PATTERN = Pattern.compile( | ||
| "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | ||
| - | ||
| - /** | ||
| - * Set when entering variable edit mode; retrieved upon exiting. | ||
| - */ | ||
| - private InputMap<InputEvent> nodeMap; | ||
| - | ||
| - private String lineSeparator = getLineSeparator(); | ||
| public MarkdownEditorPane() { | ||
| // TODO: Wait for implementation that allows cutting lines, not paragraphs. | ||
| // addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Call to hook into changes to the text area. | ||
| - * | ||
| - * @param listener The listener to receive editor change events. | ||
| - */ | ||
| - public void addChangeListener( ChangeListener<? super String> listener ) { | ||
| - getEditor().textProperty().addListener( listener ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * This method adds listeners to editor events. | ||
| - * | ||
| - * @param <T> The event type. | ||
| - * @param <U> The consumer type for the given event type. | ||
| - * @param event The event of interest. | ||
| - * @param consumer The method to call when the event happens. | ||
| - */ | ||
| - public <T extends Event, U extends T> void addEventListener( | ||
| - final EventPattern<? super T, ? extends U> event, | ||
| - final Consumer<? super U> consumer ) { | ||
| - Nodes.addInputMap( getEditor(), consume( event, consumer ) ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * This method adds listeners to editor events that can be removed without | ||
| - * affecting the original listeners (i.e., the original lister is restored on | ||
| - * a call to removeEventListener). | ||
| - * | ||
| - * @param map The map of methods to events. | ||
| - */ | ||
| - @SuppressWarnings( "unchecked" ) | ||
| - public void addEventListener( final InputMap<InputEvent> map ) { | ||
| - this.nodeMap = (InputMap<InputEvent>)getInputMap(); | ||
| - Nodes.addInputMap( getEditor(), map ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * This method removes listeners to editor events and restores the default | ||
| - * handler. | ||
| - * | ||
| - * @param map The map of methods to events. | ||
| - */ | ||
| - public void removeEventListener( final InputMap<InputEvent> map ) { | ||
| - Nodes.removeInputMap( getEditor(), map ); | ||
| - Nodes.addInputMap( getEditor(), this.nodeMap ); | ||
| } | ||
| */ | ||
| private void initOptionEventListener() { | ||
| - final StyleClassedTextArea textArea = getEditor(); | ||
| - | ||
| final InvalidationListener listener = e -> { | ||
| - if( textArea.getScene() == null ) { | ||
| - // Editor closed but not yet garbage collected. | ||
| - return; | ||
| - } | ||
| - | ||
| - // Re-process markdown if markdown extensions option changes. | ||
| - if( e == getOptions().markdownExtensionsProperty() ) { | ||
| - // TODO: Watch for invalidation events. | ||
| - //textChanged(textArea.getText()); | ||
| - } | ||
| }; | ||
| WeakInvalidationListener weakOptionsListener = new WeakInvalidationListener( listener ); | ||
| getOptions().markdownExtensionsProperty().addListener( weakOptionsListener ); | ||
| - } | ||
| - | ||
| - private String getLineSeparator() { | ||
| - final String separator = getOptions().getLineSeparator(); | ||
| - | ||
| - return (separator != null) | ||
| - ? separator | ||
| - : System.lineSeparator(); | ||
| - } | ||
| - | ||
| - private String determineLineSeparator( final String s ) { | ||
| - final int length = s.length(); | ||
| - | ||
| - // TODO: Looping backwards will probably detect a newline sooner. | ||
| - for( int i = 0; i < length; i++ ) { | ||
| - char ch = s.charAt( i ); | ||
| - if( ch == '\n' ) { | ||
| - return (i > 0 && s.charAt( i - 1 ) == '\r') ? "\r\n" : "\n"; | ||
| - } | ||
| - } | ||
| - | ||
| - return getLineSeparator(); | ||
| - } | ||
| - | ||
| - public String getMarkdown() { | ||
| - String markdown = getEditor().getText(); | ||
| - | ||
| - if( !this.lineSeparator.equals( "\n" ) ) { | ||
| - markdown = markdown.replace( "\n", this.lineSeparator ); | ||
| - } | ||
| - | ||
| - return markdown; | ||
| - } | ||
| - | ||
| - public void setMarkdown( final String markdown ) { | ||
| - this.lineSeparator = determineLineSeparator( markdown ); | ||
| - getEditor().deselect(); | ||
| - getEditor().replaceText( markdown ); | ||
| - getUndoManager().mark(); | ||
| } | ||
| final HyperlinkModel model = createHyperlinkModel( | ||
| - link, selectedText, "http://website.com" | ||
| + link, selectedText, "https://website.com" | ||
| ); | ||
| public void insertImage() { | ||
| insertObject( createImageDialog() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the value for "org.fxmisc.wellbehaved.event.inputmap". | ||
| - * | ||
| - * @return An input map of input events. | ||
| - */ | ||
| - private Object getInputMap() { | ||
| - return getEditor().getProperties().get( getInputMapKey() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the hashmap key entry for the input map. | ||
| - * | ||
| - * @return "org.fxmisc.wellbehaved.event.inputmap" | ||
| - */ | ||
| - private String getInputMapKey() { | ||
| - return "org.fxmisc.wellbehaved.event.inputmap"; | ||
| } | ||
| import static com.scrivenvar.Constants.SEPARATOR; | ||
| -import com.scrivenvar.FileEditorPane; | ||
| -import com.scrivenvar.Services; | ||
| -import com.scrivenvar.decorators.VariableDecorator; | ||
| -import com.scrivenvar.decorators.YamlVariableDecorator; | ||
| -import com.scrivenvar.definition.DefinitionPane; | ||
| -import static com.scrivenvar.definition.Lists.getFirst; | ||
| -import static com.scrivenvar.definition.Lists.getLast; | ||
| -import com.scrivenvar.service.Settings; | ||
| -import com.scrivenvar.ui.VariableTreeItem; | ||
| -import static java.lang.Character.isSpaceChar; | ||
| -import static java.lang.Character.isWhitespace; | ||
| -import static java.lang.Math.min; | ||
| -import java.util.function.Consumer; | ||
| -import javafx.collections.ObservableList; | ||
| -import javafx.event.Event; | ||
| -import javafx.scene.control.IndexRange; | ||
| -import javafx.scene.control.TreeItem; | ||
| -import javafx.scene.input.InputEvent; | ||
| -import javafx.scene.input.KeyCode; | ||
| -import static javafx.scene.input.KeyCode.AT; | ||
| -import static javafx.scene.input.KeyCode.DIGIT2; | ||
| -import static javafx.scene.input.KeyCode.ENTER; | ||
| -import static javafx.scene.input.KeyCode.MINUS; | ||
| -import static javafx.scene.input.KeyCode.SPACE; | ||
| -import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | ||
| -import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | ||
| -import javafx.scene.input.KeyEvent; | ||
| -import org.fxmisc.richtext.StyledTextArea; | ||
| -import org.fxmisc.wellbehaved.event.EventPattern; | ||
| -import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | ||
| -import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | ||
| -import org.fxmisc.wellbehaved.event.InputMap; | ||
| -import static org.fxmisc.wellbehaved.event.InputMap.consume; | ||
| -import static org.fxmisc.wellbehaved.event.InputMap.sequence; | ||
| - | ||
| -/** | ||
| - * Provides the logic for injecting variable names within the editor. | ||
| - * | ||
| - * @author White Magic Software, Ltd. | ||
| - */ | ||
| -public class VariableNameInjector { | ||
| - | ||
| - public static final int DEFAULT_MAX_VAR_LENGTH = 64; | ||
| - | ||
| - private static final int NO_DIFFERENCE = -1; | ||
| - | ||
| - private final Settings settings = Services.load( Settings.class ); | ||
| - | ||
| - /** | ||
| - * Used to capture keyboard events once the user presses @. | ||
| - */ | ||
| - private InputMap<InputEvent> keyboardMap; | ||
| - | ||
| - private FileEditorPane fileEditorPane; | ||
| - private DefinitionPane definitionPane; | ||
| - | ||
| - /** | ||
| - * Position of the variable in the text when in variable mode (0 by default). | ||
| - */ | ||
| - private int initialCaretPosition; | ||
| - | ||
| - public VariableNameInjector( | ||
| - final FileEditorPane editorPane, | ||
| - final DefinitionPane definitionPane ) { | ||
| - setFileEditorPane( editorPane ); | ||
| - setDefinitionPane( definitionPane ); | ||
| - | ||
| - initKeyboardEventListeners(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Traps keys for performing various short-cut tasks, such as @-mode variable | ||
| - * insertion and control+space for variable autocomplete. | ||
| - * | ||
| - * @ key is pressed, a new keyboard map is inserted in place of the current | ||
| - * map -- this class goes into "variable edit mode" (a.k.a. vMode). | ||
| - * | ||
| - * @see createKeyboardMap() | ||
| - */ | ||
| - private void initKeyboardEventListeners() { | ||
| - addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete ); | ||
| - | ||
| - // @ key in Linux? | ||
| - addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode ); | ||
| - // @ key in Windows. | ||
| - addEventListener( keyPressed( AT ), this::vMode ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * The @ symbol is a short-cut to inserting a YAML variable reference. | ||
| - * | ||
| - * @param e Superfluous information about the key that was pressed. | ||
| - */ | ||
| - private void vMode( KeyEvent e ) { | ||
| - setInitialCaretPosition(); | ||
| - vModeStart(); | ||
| - vModeAutocomplete(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Receives key presses until the user completes the variable selection. This | ||
| - * allows the arrow keys to be used for selecting variables. | ||
| - * | ||
| - * @param e The key that was pressed. | ||
| - */ | ||
| - private void vModeKeyPressed( KeyEvent e ) { | ||
| - final KeyCode keyCode = e.getCode(); | ||
| - | ||
| - switch( keyCode ) { | ||
| - case BACK_SPACE: | ||
| - // Don't decorate the variable upon exiting vMode. | ||
| - vModeBackspace(); | ||
| - break; | ||
| - | ||
| - case ESCAPE: | ||
| - // Don't decorate the variable upon exiting vMode. | ||
| - vModeStop(); | ||
| - break; | ||
| - | ||
| - case ENTER: | ||
| - case PERIOD: | ||
| - case RIGHT: | ||
| - case END: | ||
| - // Stop at a leaf node, ENTER means accept. | ||
| - if( vModeConditionalComplete() && keyCode == ENTER ) { | ||
| - vModeStop(); | ||
| - | ||
| - // Decorate the variable upon exiting vMode. | ||
| - decorateVariable(); | ||
| - } | ||
| - break; | ||
| - | ||
| - case UP: | ||
| - cyclePathPrev(); | ||
| - break; | ||
| - | ||
| - case DOWN: | ||
| - cyclePathNext(); | ||
| - break; | ||
| - | ||
| - default: | ||
| - vModeFilterKeyPressed( e ); | ||
| - break; | ||
| - } | ||
| - | ||
| - e.consume(); | ||
| - } | ||
| - | ||
| - private void vModeBackspace() { | ||
| - deleteSelection(); | ||
| - | ||
| - // Break out of variable mode by back spacing to the original position. | ||
| - if( getCurrentCaretPosition() > getInitialCaretPosition() ) { | ||
| - vModeAutocomplete(); | ||
| - } else { | ||
| - vModeStop(); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Updates the text with the path selected (or typed) by the user. | ||
| - */ | ||
| - private void vModeAutocomplete() { | ||
| - final TreeItem<String> node = getCurrentNode(); | ||
| - | ||
| - if( !node.isLeaf() ) { | ||
| - final String word = getLastPathWord(); | ||
| - final String label = node.getValue(); | ||
| - final int delta = difference( label, word ); | ||
| - final String remainder = delta == NO_DIFFERENCE | ||
| - ? label | ||
| - : label.substring( delta ); | ||
| - | ||
| - final StyledTextArea textArea = getEditor(); | ||
| - final int posBegan = getCurrentCaretPosition(); | ||
| - final int posEnded = posBegan + remainder.length(); | ||
| - | ||
| - textArea.replaceSelection( remainder ); | ||
| - | ||
| - if( posEnded - posBegan > 0 ) { | ||
| - textArea.selectRange( posEnded, posBegan ); | ||
| - } | ||
| - | ||
| - expand( node ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Only variable name keys can pass through the filter. This is called when | ||
| - * the user presses a key. | ||
| - * | ||
| - * @param e The key that was pressed. | ||
| - */ | ||
| - private void vModeFilterKeyPressed( final KeyEvent e ) { | ||
| - if( isVariableNameKey( e ) ) { | ||
| - typed( e.getText() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Performs an autocomplete depending on whether the user has finished typing | ||
| - * in a word. If there is a selected range, then this will complete the most | ||
| - * recent word and jump to the next child. | ||
| - * | ||
| - * @return true The auto-completed node was a terminal node. | ||
| - */ | ||
| - private boolean vModeConditionalComplete() { | ||
| - acceptPath(); | ||
| - | ||
| - final TreeItem<String> node = getCurrentNode(); | ||
| - final boolean terminal = isTerminal( node ); | ||
| - | ||
| - if( !terminal ) { | ||
| - typed( SEPARATOR ); | ||
| - } | ||
| - | ||
| - return terminal; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Pressing control+space will find a node that matches the current word and | ||
| - * substitute the YAML variable reference. This is called when the user is not | ||
| - * editing in vMode. | ||
| - * | ||
| - * @param e Ignored -- it can only be Ctrl+Space. | ||
| - */ | ||
| - private void autocomplete( KeyEvent e ) { | ||
| - final String paragraph = getCaretParagraph(); | ||
| - final int[] boundaries = getWordBoundaries( paragraph ); | ||
| - final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | ||
| - | ||
| - final VariableTreeItem<String> leaf = findLeaf( word ); | ||
| - | ||
| - if( leaf != null ) { | ||
| - replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() ); | ||
| - decorateVariable(); | ||
| - expand( leaf ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when autocomplete finishes on a valid leaf or when the user presses | ||
| - * Enter to finish manual autocomplete. | ||
| - */ | ||
| - private void decorateVariable() { | ||
| - // A little bit of duplication... | ||
| - final String paragraph = getCaretParagraph(); | ||
| - final int[] boundaries = getWordBoundaries( paragraph ); | ||
| - final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | ||
| - | ||
| - final String newVariable = getVariableDecorator().decorate( old ); | ||
| - | ||
| - final int posEnded = getCurrentCaretPosition(); | ||
| - final int posBegan = posEnded - old.length(); | ||
| - | ||
| - getEditor().replaceText( posBegan, posEnded, newVariable ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Updates the text at the given position within the current paragraph. | ||
| - * | ||
| - * @param posBegan The starting index in the paragraph text to replace. | ||
| - * @param posEnded The ending index in the paragraph text to replace. | ||
| - * @param text Overwrite the paragraph substring with this text. | ||
| - */ | ||
| - private void replaceText( | ||
| - final int posBegan, final int posEnded, final String text ) { | ||
| - final int p = getCurrentParagraph(); | ||
| - | ||
| - getEditor().replaceText( p, posBegan, p, posEnded, text ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the caret's current paragraph position. | ||
| - * | ||
| - * @return A number greater than or equal to 0. | ||
| - */ | ||
| - private int getCurrentParagraph() { | ||
| - return getEditor().getCurrentParagraph(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns current word boundary indexes into the current paragraph, including | ||
| - * punctuation. | ||
| - * | ||
| - * @param p The paragraph wherein to hunt word boundaries. | ||
| - * @param offset The offset into the paragraph to begin scanning left and | ||
| - * right. | ||
| - * | ||
| - * @return The starting and ending index of the word closest to the caret. | ||
| - */ | ||
| - private int[] getWordBoundaries( final String p, final int offset ) { | ||
| - // Remove dashes, but retain hyphens. Retain same number of characters | ||
| - // to preserve relative indexes. | ||
| - final String paragraph = p.replace( "---", " " ).replace( "--", " " ); | ||
| - | ||
| - return getWordAt( paragraph, offset ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Helper method to get the word boundaries for the current paragraph. | ||
| - * | ||
| - * @param paragraph | ||
| - * | ||
| - * @return | ||
| - */ | ||
| - private int[] getWordBoundaries( final String paragraph ) { | ||
| - return getWordBoundaries( paragraph, getCurrentCaretColumn() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Given an arbitrary offset into a string, this returns the word at that | ||
| - * index. The inputs and outputs include: | ||
| - * | ||
| - * <ul> | ||
| - * <li>surrounded by space: <code>hello | world!</code> ("");</li> | ||
| - * <li>end of word: <code>hello| world!</code> ("hello");</li> | ||
| - * <li>start of a word: <code>hello |world!</code> ("world!");</li> | ||
| - * <li>within a word: <code>hello wo|rld!</code> ("world!");</li> | ||
| - * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li> | ||
| - * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li> | ||
| - * <li>after punctuation: <code>hello world!|</code> ("world!").</li> | ||
| - * </ul> | ||
| - * | ||
| - * @param p The string to scan for a word. | ||
| - * @param offset The offset within s to begin searching for the nearest word | ||
| - * boundary, must not be out of bounds of s. | ||
| - * | ||
| - * @return The word in s at the offset. | ||
| - * | ||
| - * @see getWordBegan( String, int ) | ||
| - * @see getWordEnded( String, int ) | ||
| - */ | ||
| - private int[] getWordAt( final String p, final int offset ) { | ||
| - return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) }; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the index into s where a word begins. | ||
| - * | ||
| - * @param s Never null. | ||
| - * @param offset Index into s to begin searching backwards for a word | ||
| - * boundary. | ||
| - * | ||
| - * @return The index where a word begins. | ||
| - */ | ||
| - private int getWordBegan( final String s, int offset ) { | ||
| - while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) { | ||
| - offset--; | ||
| - } | ||
| - | ||
| - return offset; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the index into s where a word ends. | ||
| - * | ||
| - * @param s Never null. | ||
| - * @param offset Index into s to begin searching forwards for a word boundary. | ||
| - * | ||
| - * @return The index where a word ends. | ||
| - */ | ||
| - private int getWordEnded( final String s, int offset ) { | ||
| - final int length = s.length(); | ||
| - | ||
| - while( offset < length && isBoundary( s.charAt( offset ) ) ) { | ||
| - offset++; | ||
| - } | ||
| - | ||
| - return offset; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns true if the given character can be reasonably expected to be part | ||
| - * of a word, including punctuation marks. | ||
| - * | ||
| - * @param c The character to compare. | ||
| - * | ||
| - * @return false The character is a space character. | ||
| - */ | ||
| - private boolean isBoundary( final char c ) { | ||
| - return !isSpaceChar( c ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the text for the paragraph that contains the caret. | ||
| - * | ||
| - * @return A non-null string, possibly empty. | ||
| - */ | ||
| - private String getCaretParagraph() { | ||
| - return getEditor().getText( getCurrentParagraph() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns true if the node has children that can be selected (i.e., any | ||
| - * non-leaves). | ||
| - * | ||
| - * @param <T> The type that the TreeItem contains. | ||
| - * @param node The node to test for terminality. | ||
| - * | ||
| - * @return true The node has one branch and its a leaf. | ||
| - */ | ||
| - private <T> boolean isTerminal( final TreeItem<T> node ) { | ||
| - final ObservableList<TreeItem<T>> branches = node.getChildren(); | ||
| - | ||
| - return branches.size() == 1 && branches.get( 0 ).isLeaf(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Inserts text that the user typed at the current caret position, then | ||
| - * performs an autocomplete for the variable name. | ||
| - * | ||
| - * @param text The text to insert, never null. | ||
| - */ | ||
| - private void typed( final String text ) { | ||
| - getEditor().replaceSelection( text ); | ||
| - vModeAutocomplete(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when the user presses either End or Enter key. | ||
| - */ | ||
| - private void acceptPath() { | ||
| - final IndexRange range = getSelectionRange(); | ||
| - | ||
| - if( range != null ) { | ||
| - final int rangeEnd = range.getEnd(); | ||
| - final StyledTextArea textArea = getEditor(); | ||
| - textArea.deselect(); | ||
| - textArea.moveTo( rangeEnd ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Replaces the entirety of the existing path (from the initial caret | ||
| - * position) with the given path. | ||
| - * | ||
| - * @param oldPath The path to replace. | ||
| - * @param newPath The replacement path. | ||
| - */ | ||
| - private void replacePath( final String oldPath, final String newPath ) { | ||
| - final StyledTextArea textArea = getEditor(); | ||
| - final int posBegan = getInitialCaretPosition(); | ||
| - final int posEnded = posBegan + oldPath.length(); | ||
| - | ||
| - textArea.deselect(); | ||
| - textArea.replaceText( posBegan, posEnded, newPath ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Called when the user presses the Backspace key. | ||
| - */ | ||
| - private void deleteSelection() { | ||
| - final StyledTextArea textArea = getEditor(); | ||
| - textArea.replaceSelection( "" ); | ||
| - textArea.deletePreviousChar(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Cycles the selected text through the nodes. | ||
| - * | ||
| - * @param direction true - next; false - previous | ||
| - */ | ||
| - private void cycleSelection( final boolean direction ) { | ||
| - final TreeItem<String> node = getCurrentNode(); | ||
| - | ||
| - // Find the sibling for the current selection and replace the current | ||
| - // selection with the sibling's value | ||
| - TreeItem< String> cycled = direction | ||
| - ? node.nextSibling() | ||
| - : node.previousSibling(); | ||
| - | ||
| - // When cycling at the end (or beginning) of the list, jump to the first | ||
| - // (or last) sibling depending on the cycle direction. | ||
| - if( cycled == null ) { | ||
| - cycled = direction ? getFirstSibling( node ) : getLastSibling( node ); | ||
| - } | ||
| - | ||
| - final String path = getCurrentPath(); | ||
| - final String cycledWord = cycled.getValue(); | ||
| - final String word = getLastPathWord(); | ||
| - final int index = path.indexOf( word ); | ||
| - final String cycledPath = path.substring( 0, index ) + cycledWord; | ||
| - | ||
| - expand( cycled ); | ||
| - replacePath( path, cycledPath ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Cycles to the next sibling of the currently selected tree node. | ||
| - */ | ||
| - private void cyclePathNext() { | ||
| - cycleSelection( true ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Cycles to the previous sibling of the currently selected tree node. | ||
| - */ | ||
| - private void cyclePathPrev() { | ||
| - cycleSelection( false ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the variable name (or as much as has been typed so far). Returns | ||
| - * all the characters from the initial caret column to the the first | ||
| - * whitespace character. This will return a path that contains zero or more | ||
| - * separators. | ||
| - * | ||
| - * @return A non-null string, possibly empty. | ||
| - */ | ||
| - private String getCurrentPath() { | ||
| - final String s = extractTextChunk(); | ||
| - final int length = s.length(); | ||
| - | ||
| - int i = 0; | ||
| - | ||
| - while( i < length && !isWhitespace( s.charAt( i ) ) ) { | ||
| - i++; | ||
| - } | ||
| - | ||
| - return s.substring( 0, i ); | ||
| - } | ||
| - | ||
| - private <T> ObservableList<TreeItem<T>> getSiblings( | ||
| - final TreeItem<T> item ) { | ||
| - final TreeItem<T> parent = item.getParent(); | ||
| - return parent == null ? item.getChildren() : parent.getChildren(); | ||
| - } | ||
| - | ||
| - private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) { | ||
| - return getFirst( getSiblings( item ), item ); | ||
| - } | ||
| - | ||
| - private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) { | ||
| - return getLast( getSiblings( item ), item ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the caret position as an offset into the text. | ||
| - * | ||
| - * @return A value from 0 to the length of the text (minus one). | ||
| - */ | ||
| - private int getCurrentCaretPosition() { | ||
| - return getEditor().getCaretPosition(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the caret position within the current paragraph. | ||
| - * | ||
| - * @return A value from 0 to the length of the current paragraph. | ||
| - */ | ||
| - private int getCurrentCaretColumn() { | ||
| - return getEditor().getCaretColumn(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the last word from the path. | ||
| - * | ||
| - * @return The last token. | ||
| - */ | ||
| - private String getLastPathWord() { | ||
| - String path = getCurrentPath(); | ||
| - | ||
| - int i = path.indexOf( SEPARATOR ); | ||
| - | ||
| - while( i > 0 ) { | ||
| - path = path.substring( i + 1 ); | ||
| - i = path.indexOf( SEPARATOR ); | ||
| - } | ||
| - | ||
| - return path; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns text from the initial caret position until some arbitrarily long | ||
| - * number of characters. The number of characters extracted will be | ||
| - * getMaxVarLength, or fewer, depending on how many characters remain to be | ||
| - * extracted. The result from this method is trimmed to the first whitespace | ||
| - * character. | ||
| - * | ||
| - * @return A chunk of text that includes all the words representing a path, | ||
| - * and then some. | ||
| - */ | ||
| - private String extractTextChunk() { | ||
| - final StyledTextArea textArea = getEditor(); | ||
| - final int textBegan = getInitialCaretPosition(); | ||
| - final int remaining = textArea.getLength() - textBegan; | ||
| - final int textEnded = min( remaining, getMaxVarLength() ); | ||
| - | ||
| - return textArea.getText( textBegan, textEnded ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the node for the current path. | ||
| - */ | ||
| - private TreeItem<String> getCurrentNode() { | ||
| - return findNode( getCurrentPath() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Finds the node that most closely matches the given path. | ||
| - * | ||
| - * @param path The path that represents a node. | ||
| - * | ||
| - * @return The node for the path, or the root node if the path could not be | ||
| - * found, but never null. | ||
| - */ | ||
| - private TreeItem<String> findNode( final String path ) { | ||
| - return getDefinitionPane().findNode( path ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Finds the first leaf having a value that starts with the given text. | ||
| - * | ||
| - * @param text The text to find in the definition tree. | ||
| - * | ||
| - * @return The leaf that starts with the given text, or null if not found. | ||
| - */ | ||
| - private VariableTreeItem<String> findLeaf( final String text ) { | ||
| - return getDefinitionPane().findLeaf( text ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Used to ignore typed keys in favour of trapping pressed keys. | ||
| - * | ||
| - * @param e The key that was typed. | ||
| - */ | ||
| - private void vModeKeyTyped( KeyEvent e ) { | ||
| - e.consume(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Used to lazily initialize the keyboard map. | ||
| - * | ||
| - * @return Mappings for keyTyped and keyPressed. | ||
| - */ | ||
| - protected InputMap<InputEvent> createKeyboardMap() { | ||
| - return sequence( | ||
| - consume( keyTyped(), this::vModeKeyTyped ), | ||
| - consume( keyPressed(), this::vModeKeyPressed ) | ||
| - ); | ||
| - } | ||
| - | ||
| - private InputMap<InputEvent> getKeyboardMap() { | ||
| - if( this.keyboardMap == null ) { | ||
| - this.keyboardMap = createKeyboardMap(); | ||
| - } | ||
| - | ||
| - return this.keyboardMap; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Collapses the tree then expands and selects the given node. | ||
| - * | ||
| - * @param node The node to expand. | ||
| - */ | ||
| - private void expand( final TreeItem<String> node ) { | ||
| - final DefinitionPane pane = getDefinitionPane(); | ||
| - pane.collapse(); | ||
| - pane.expand( node ); | ||
| - pane.select( node ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns true iff the key code the user typed can be used as part of a YAML | ||
| - * variable name. | ||
| - * | ||
| - * @param keyEvent Keyboard key press event information. | ||
| - * | ||
| - * @return true The key is a value that can be inserted into the text. | ||
| - */ | ||
| - private boolean isVariableNameKey( final KeyEvent keyEvent ) { | ||
| - final KeyCode kc = keyEvent.getCode(); | ||
| - | ||
| - return (kc.isLetterKey() | ||
| - || kc.isDigitKey() | ||
| - || (keyEvent.isShiftDown() && kc == MINUS)) | ||
| - && !keyEvent.isControlDown(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Starts to capture user input events. | ||
| - */ | ||
| - private void vModeStart() { | ||
| - addEventListener( getKeyboardMap() ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Restores capturing of user input events to the previous event listener. | ||
| - * Also asks the processing chain to modify the variable text into a | ||
| - * machine-readable variable based on the format required by the file type. | ||
| - * For example, a Markdown file (.md) will substitute a $VAR$ name while an R | ||
| - * file (.Rmd, .Rxml) will use `r#xVAR`. | ||
| - */ | ||
| - private void vModeStop() { | ||
| - removeEventListener( getKeyboardMap() ); | ||
| - } | ||
| - | ||
| - private VariableDecorator getVariableDecorator() { | ||
| - return new YamlVariableDecorator(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the index where the two strings diverge. | ||
| - * | ||
| - * @param s1 The string that could be a substring of s2, null allowed. | ||
| - * @param s2 The string that could be a substring of s1, null allowed. | ||
| - * | ||
| - * @return NO_DIFFERENCE if the strings are the same, otherwise the index | ||
| - * where they differ. | ||
| - */ | ||
| - @SuppressWarnings( "StringEquality" ) | ||
| - private int difference( final CharSequence s1, final CharSequence s2 ) { | ||
| - if( s1 == s2 ) { | ||
| - return NO_DIFFERENCE; | ||
| - } | ||
| - | ||
| - if( s1 == null || s2 == null ) { | ||
| - return 0; | ||
| - } | ||
| - | ||
| - int i = 0; | ||
| - final int limit = min( s1.length(), s2.length() ); | ||
| - | ||
| - while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) { | ||
| - i++; | ||
| - } | ||
| - | ||
| - // If one string was shorter than the other, that's where they differ. | ||
| - return i; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegates to the file editor pane, and, ultimately, to its text area. | ||
| - */ | ||
| - private <T extends Event, U extends T> void addEventListener( | ||
| - final EventPattern<? super T, ? extends U> event, | ||
| - final Consumer<? super U> consumer ) { | ||
| - getFileEditorPane().addEventListener( event, consumer ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Delegates to the file editor pane, and, ultimately, to its text area. | ||
| - * | ||
| - * @param map The map of methods to events. | ||
| - */ | ||
| - private void addEventListener( final InputMap<InputEvent> map ) { | ||
| - getFileEditorPane().addEventListener( map ); | ||
| - } | ||
| - | ||
| - private void removeEventListener( final InputMap<InputEvent> map ) { | ||
| - getFileEditorPane().removeEventListener( map ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the position of the caret when variable mode editing was requested. | ||
| - * | ||
| - * @return The variable mode caret position. | ||
| - */ | ||
| - private int getInitialCaretPosition() { | ||
| - return this.initialCaretPosition; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Sets the position of the caret when variable mode editing was requested. | ||
| - * Stores the current position because only the text that comes afterwards is | ||
| - * a suitable variable reference. | ||
| - * | ||
| - * @return The variable mode caret position. | ||
| - */ | ||
| - private void setInitialCaretPosition() { | ||
| - this.initialCaretPosition = getEditor().getCaretPosition(); | ||
| - } | ||
| - | ||
| - private StyledTextArea getEditor() { | ||
| - return getFileEditorPane().getEditor(); | ||
| - } | ||
| - | ||
| - public FileEditorPane getFileEditorPane() { | ||
| - return this.fileEditorPane; | ||
| - } | ||
| - | ||
| - private void setFileEditorPane( final FileEditorPane fileEditorPane ) { | ||
| +import com.scrivenvar.FileEditorTabPane; | ||
| +import com.scrivenvar.Services; | ||
| +import com.scrivenvar.decorators.VariableDecorator; | ||
| +import com.scrivenvar.decorators.YamlVariableDecorator; | ||
| +import com.scrivenvar.definition.DefinitionPane; | ||
| +import static com.scrivenvar.definition.Lists.getFirst; | ||
| +import static com.scrivenvar.definition.Lists.getLast; | ||
| +import com.scrivenvar.service.Settings; | ||
| +import com.scrivenvar.ui.VariableTreeItem; | ||
| +import static java.lang.Character.isSpaceChar; | ||
| +import static java.lang.Character.isWhitespace; | ||
| +import static java.lang.Math.min; | ||
| +import java.util.function.Consumer; | ||
| +import javafx.collections.ObservableList; | ||
| +import javafx.event.Event; | ||
| +import javafx.scene.control.IndexRange; | ||
| +import javafx.scene.control.TreeItem; | ||
| +import javafx.scene.input.InputEvent; | ||
| +import javafx.scene.input.KeyCode; | ||
| +import static javafx.scene.input.KeyCode.AT; | ||
| +import static javafx.scene.input.KeyCode.DIGIT2; | ||
| +import static javafx.scene.input.KeyCode.ENTER; | ||
| +import static javafx.scene.input.KeyCode.MINUS; | ||
| +import static javafx.scene.input.KeyCode.SPACE; | ||
| +import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | ||
| +import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | ||
| +import javafx.scene.input.KeyEvent; | ||
| +import org.fxmisc.richtext.StyledTextArea; | ||
| +import org.fxmisc.wellbehaved.event.EventPattern; | ||
| +import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | ||
| +import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | ||
| +import org.fxmisc.wellbehaved.event.InputMap; | ||
| +import static org.fxmisc.wellbehaved.event.InputMap.consume; | ||
| +import static org.fxmisc.wellbehaved.event.InputMap.sequence; | ||
| +import static com.scrivenvar.definition.Lists.getFirst; | ||
| +import static com.scrivenvar.definition.Lists.getLast; | ||
| +import static java.lang.Character.isSpaceChar; | ||
| +import static java.lang.Character.isWhitespace; | ||
| +import static java.lang.Math.min; | ||
| +import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | ||
| +import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped; | ||
| +import static org.fxmisc.wellbehaved.event.InputMap.consume; | ||
| + | ||
| +/** | ||
| + * Provides the logic for injecting variable names within the editor. | ||
| + * | ||
| + * @author White Magic Software, Ltd. | ||
| + */ | ||
| +public class VariableNameInjector { | ||
| + | ||
| + public static final int DEFAULT_MAX_VAR_LENGTH = 64; | ||
| + | ||
| + private static final int NO_DIFFERENCE = -1; | ||
| + | ||
| + private final Settings settings = Services.load( Settings.class ); | ||
| + | ||
| + /** | ||
| + * Used to capture keyboard events once the user presses @. | ||
| + */ | ||
| + private InputMap<InputEvent> keyboardMap; | ||
| + | ||
| + private FileEditorTabPane fileEditorPane; | ||
| + private DefinitionPane definitionPane; | ||
| + | ||
| + /** | ||
| + * Position of the variable in the text when in variable mode (0 by default). | ||
| + */ | ||
| + private int initialCaretPosition; | ||
| + | ||
| + public VariableNameInjector( | ||
| + final FileEditorTabPane editorPane, | ||
| + final DefinitionPane definitionPane ) { | ||
| + setFileEditorPane( editorPane ); | ||
| + setDefinitionPane( definitionPane ); | ||
| + | ||
| + initKeyboardEventListeners(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Traps keys for performing various short-cut tasks, such as @-mode variable | ||
| + * insertion and control+space for variable autocomplete. | ||
| + * | ||
| + * @ key is pressed, a new keyboard map is inserted in place of the current | ||
| + * map -- this class goes into "variable edit mode" (a.k.a. vMode). | ||
| + * | ||
| + * @see createKeyboardMap() | ||
| + */ | ||
| + private void initKeyboardEventListeners() { | ||
| + addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete ); | ||
| + | ||
| + // @ key in Linux? | ||
| + addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode ); | ||
| + // @ key in Windows. | ||
| + addEventListener( keyPressed( AT ), this::vMode ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * The @ symbol is a short-cut to inserting a YAML variable reference. | ||
| + * | ||
| + * @param e Superfluous information about the key that was pressed. | ||
| + */ | ||
| + private void vMode( KeyEvent e ) { | ||
| + setInitialCaretPosition(); | ||
| + vModeStart(); | ||
| + vModeAutocomplete(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Receives key presses until the user completes the variable selection. This | ||
| + * allows the arrow keys to be used for selecting variables. | ||
| + * | ||
| + * @param e The key that was pressed. | ||
| + */ | ||
| + private void vModeKeyPressed( KeyEvent e ) { | ||
| + final KeyCode keyCode = e.getCode(); | ||
| + | ||
| + switch( keyCode ) { | ||
| + case BACK_SPACE: | ||
| + // Don't decorate the variable upon exiting vMode. | ||
| + vModeBackspace(); | ||
| + break; | ||
| + | ||
| + case ESCAPE: | ||
| + // Don't decorate the variable upon exiting vMode. | ||
| + vModeStop(); | ||
| + break; | ||
| + | ||
| + case ENTER: | ||
| + case PERIOD: | ||
| + case RIGHT: | ||
| + case END: | ||
| + // Stop at a leaf node, ENTER means accept. | ||
| + if( vModeConditionalComplete() && keyCode == ENTER ) { | ||
| + vModeStop(); | ||
| + | ||
| + // Decorate the variable upon exiting vMode. | ||
| + decorateVariable(); | ||
| + } | ||
| + break; | ||
| + | ||
| + case UP: | ||
| + cyclePathPrev(); | ||
| + break; | ||
| + | ||
| + case DOWN: | ||
| + cyclePathNext(); | ||
| + break; | ||
| + | ||
| + default: | ||
| + vModeFilterKeyPressed( e ); | ||
| + break; | ||
| + } | ||
| + | ||
| + e.consume(); | ||
| + } | ||
| + | ||
| + private void vModeBackspace() { | ||
| + deleteSelection(); | ||
| + | ||
| + // Break out of variable mode by back spacing to the original position. | ||
| + if( getCurrentCaretPosition() > getInitialCaretPosition() ) { | ||
| + vModeAutocomplete(); | ||
| + } else { | ||
| + vModeStop(); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Updates the text with the path selected (or typed) by the user. | ||
| + */ | ||
| + private void vModeAutocomplete() { | ||
| + final TreeItem<String> node = getCurrentNode(); | ||
| + | ||
| + if( !node.isLeaf() ) { | ||
| + final String word = getLastPathWord(); | ||
| + final String label = node.getValue(); | ||
| + final int delta = difference( label, word ); | ||
| + final String remainder = delta == NO_DIFFERENCE | ||
| + ? label | ||
| + : label.substring( delta ); | ||
| + | ||
| + final StyledTextArea textArea = getEditor(); | ||
| + final int posBegan = getCurrentCaretPosition(); | ||
| + final int posEnded = posBegan + remainder.length(); | ||
| + | ||
| + textArea.replaceSelection( remainder ); | ||
| + | ||
| + if( posEnded - posBegan > 0 ) { | ||
| + textArea.selectRange( posEnded, posBegan ); | ||
| + } | ||
| + | ||
| + expand( node ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Only variable name keys can pass through the filter. This is called when | ||
| + * the user presses a key. | ||
| + * | ||
| + * @param e The key that was pressed. | ||
| + */ | ||
| + private void vModeFilterKeyPressed( final KeyEvent e ) { | ||
| + if( isVariableNameKey( e ) ) { | ||
| + typed( e.getText() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Performs an autocomplete depending on whether the user has finished typing | ||
| + * in a word. If there is a selected range, then this will complete the most | ||
| + * recent word and jump to the next child. | ||
| + * | ||
| + * @return true The auto-completed node was a terminal node. | ||
| + */ | ||
| + private boolean vModeConditionalComplete() { | ||
| + acceptPath(); | ||
| + | ||
| + final TreeItem<String> node = getCurrentNode(); | ||
| + final boolean terminal = isTerminal( node ); | ||
| + | ||
| + if( !terminal ) { | ||
| + typed( SEPARATOR ); | ||
| + } | ||
| + | ||
| + return terminal; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Pressing control+space will find a node that matches the current word and | ||
| + * substitute the YAML variable reference. This is called when the user is not | ||
| + * editing in vMode. | ||
| + * | ||
| + * @param e Ignored -- it can only be Ctrl+Space. | ||
| + */ | ||
| + private void autocomplete( KeyEvent e ) { | ||
| + final String paragraph = getCaretParagraph(); | ||
| + final int[] boundaries = getWordBoundaries( paragraph ); | ||
| + final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | ||
| + | ||
| + final VariableTreeItem<String> leaf = findLeaf( word ); | ||
| + | ||
| + if( leaf != null ) { | ||
| + replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() ); | ||
| + decorateVariable(); | ||
| + expand( leaf ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Called when autocomplete finishes on a valid leaf or when the user presses | ||
| + * Enter to finish manual autocomplete. | ||
| + */ | ||
| + private void decorateVariable() { | ||
| + // A little bit of duplication... | ||
| + final String paragraph = getCaretParagraph(); | ||
| + final int[] boundaries = getWordBoundaries( paragraph ); | ||
| + final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] ); | ||
| + | ||
| + final String newVariable = getVariableDecorator().decorate( old ); | ||
| + | ||
| + final int posEnded = getCurrentCaretPosition(); | ||
| + final int posBegan = posEnded - old.length(); | ||
| + | ||
| + getEditor().replaceText( posBegan, posEnded, newVariable ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Updates the text at the given position within the current paragraph. | ||
| + * | ||
| + * @param posBegan The starting index in the paragraph text to replace. | ||
| + * @param posEnded The ending index in the paragraph text to replace. | ||
| + * @param text Overwrite the paragraph substring with this text. | ||
| + */ | ||
| + private void replaceText( | ||
| + final int posBegan, final int posEnded, final String text ) { | ||
| + final int p = getCurrentParagraph(); | ||
| + | ||
| + getEditor().replaceText( p, posBegan, p, posEnded, text ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the caret's current paragraph position. | ||
| + * | ||
| + * @return A number greater than or equal to 0. | ||
| + */ | ||
| + private int getCurrentParagraph() { | ||
| + return getEditor().getCurrentParagraph(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns current word boundary indexes into the current paragraph, including | ||
| + * punctuation. | ||
| + * | ||
| + * @param p The paragraph wherein to hunt word boundaries. | ||
| + * @param offset The offset into the paragraph to begin scanning left and | ||
| + * right. | ||
| + * | ||
| + * @return The starting and ending index of the word closest to the caret. | ||
| + */ | ||
| + private int[] getWordBoundaries( final String p, final int offset ) { | ||
| + // Remove dashes, but retain hyphens. Retain same number of characters | ||
| + // to preserve relative indexes. | ||
| + final String paragraph = p.replace( "---", " " ).replace( "--", " " ); | ||
| + | ||
| + return getWordAt( paragraph, offset ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Helper method to get the word boundaries for the current paragraph. | ||
| + * | ||
| + * @param paragraph | ||
| + * | ||
| + * @return | ||
| + */ | ||
| + private int[] getWordBoundaries( final String paragraph ) { | ||
| + return getWordBoundaries( paragraph, getCurrentCaretColumn() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Given an arbitrary offset into a string, this returns the word at that | ||
| + * index. The inputs and outputs include: | ||
| + * | ||
| + * <ul> | ||
| + * <li>surrounded by space: <code>hello | world!</code> ("");</li> | ||
| + * <li>end of word: <code>hello| world!</code> ("hello");</li> | ||
| + * <li>start of a word: <code>hello |world!</code> ("world!");</li> | ||
| + * <li>within a word: <code>hello wo|rld!</code> ("world!");</li> | ||
| + * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li> | ||
| + * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li> | ||
| + * <li>after punctuation: <code>hello world!|</code> ("world!").</li> | ||
| + * </ul> | ||
| + * | ||
| + * @param p The string to scan for a word. | ||
| + * @param offset The offset within s to begin searching for the nearest word | ||
| + * boundary, must not be out of bounds of s. | ||
| + * | ||
| + * @return The word in s at the offset. | ||
| + * | ||
| + * @see getWordBegan( String, int ) | ||
| + * @see getWordEnded( String, int ) | ||
| + */ | ||
| + private int[] getWordAt( final String p, final int offset ) { | ||
| + return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) }; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the index into s where a word begins. | ||
| + * | ||
| + * @param s Never null. | ||
| + * @param offset Index into s to begin searching backwards for a word | ||
| + * boundary. | ||
| + * | ||
| + * @return The index where a word begins. | ||
| + */ | ||
| + private int getWordBegan( final String s, int offset ) { | ||
| + while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) { | ||
| + offset--; | ||
| + } | ||
| + | ||
| + return offset; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the index into s where a word ends. | ||
| + * | ||
| + * @param s Never null. | ||
| + * @param offset Index into s to begin searching forwards for a word boundary. | ||
| + * | ||
| + * @return The index where a word ends. | ||
| + */ | ||
| + private int getWordEnded( final String s, int offset ) { | ||
| + final int length = s.length(); | ||
| + | ||
| + while( offset < length && isBoundary( s.charAt( offset ) ) ) { | ||
| + offset++; | ||
| + } | ||
| + | ||
| + return offset; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns true if the given character can be reasonably expected to be part | ||
| + * of a word, including punctuation marks. | ||
| + * | ||
| + * @param c The character to compare. | ||
| + * | ||
| + * @return false The character is a space character. | ||
| + */ | ||
| + private boolean isBoundary( final char c ) { | ||
| + return !isSpaceChar( c ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the text for the paragraph that contains the caret. | ||
| + * | ||
| + * @return A non-null string, possibly empty. | ||
| + */ | ||
| + private String getCaretParagraph() { | ||
| + return getEditor().getText( getCurrentParagraph() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns true if the node has children that can be selected (i.e., any | ||
| + * non-leaves). | ||
| + * | ||
| + * @param <T> The type that the TreeItem contains. | ||
| + * @param node The node to test for terminality. | ||
| + * | ||
| + * @return true The node has one branch and its a leaf. | ||
| + */ | ||
| + private <T> boolean isTerminal( final TreeItem<T> node ) { | ||
| + final ObservableList<TreeItem<T>> branches = node.getChildren(); | ||
| + | ||
| + return branches.size() == 1 && branches.get( 0 ).isLeaf(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Inserts text that the user typed at the current caret position, then | ||
| + * performs an autocomplete for the variable name. | ||
| + * | ||
| + * @param text The text to insert, never null. | ||
| + */ | ||
| + private void typed( final String text ) { | ||
| + getEditor().replaceSelection( text ); | ||
| + vModeAutocomplete(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Called when the user presses either End or Enter key. | ||
| + */ | ||
| + private void acceptPath() { | ||
| + final IndexRange range = getSelectionRange(); | ||
| + | ||
| + if( range != null ) { | ||
| + final int rangeEnd = range.getEnd(); | ||
| + final StyledTextArea textArea = getEditor(); | ||
| + textArea.deselect(); | ||
| + textArea.moveTo( rangeEnd ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Replaces the entirety of the existing path (from the initial caret | ||
| + * position) with the given path. | ||
| + * | ||
| + * @param oldPath The path to replace. | ||
| + * @param newPath The replacement path. | ||
| + */ | ||
| + private void replacePath( final String oldPath, final String newPath ) { | ||
| + final StyledTextArea textArea = getEditor(); | ||
| + final int posBegan = getInitialCaretPosition(); | ||
| + final int posEnded = posBegan + oldPath.length(); | ||
| + | ||
| + textArea.deselect(); | ||
| + textArea.replaceText( posBegan, posEnded, newPath ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Called when the user presses the Backspace key. | ||
| + */ | ||
| + private void deleteSelection() { | ||
| + final StyledTextArea textArea = getEditor(); | ||
| + textArea.replaceSelection( "" ); | ||
| + textArea.deletePreviousChar(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Cycles the selected text through the nodes. | ||
| + * | ||
| + * @param direction true - next; false - previous | ||
| + */ | ||
| + private void cycleSelection( final boolean direction ) { | ||
| + final TreeItem<String> node = getCurrentNode(); | ||
| + | ||
| + // Find the sibling for the current selection and replace the current | ||
| + // selection with the sibling's value | ||
| + TreeItem< String> cycled = direction | ||
| + ? node.nextSibling() | ||
| + : node.previousSibling(); | ||
| + | ||
| + // When cycling at the end (or beginning) of the list, jump to the first | ||
| + // (or last) sibling depending on the cycle direction. | ||
| + if( cycled == null ) { | ||
| + cycled = direction ? getFirstSibling( node ) : getLastSibling( node ); | ||
| + } | ||
| + | ||
| + final String path = getCurrentPath(); | ||
| + final String cycledWord = cycled.getValue(); | ||
| + final String word = getLastPathWord(); | ||
| + final int index = path.indexOf( word ); | ||
| + final String cycledPath = path.substring( 0, index ) + cycledWord; | ||
| + | ||
| + expand( cycled ); | ||
| + replacePath( path, cycledPath ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Cycles to the next sibling of the currently selected tree node. | ||
| + */ | ||
| + private void cyclePathNext() { | ||
| + cycleSelection( true ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Cycles to the previous sibling of the currently selected tree node. | ||
| + */ | ||
| + private void cyclePathPrev() { | ||
| + cycleSelection( false ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the variable name (or as much as has been typed so far). Returns | ||
| + * all the characters from the initial caret column to the the first | ||
| + * whitespace character. This will return a path that contains zero or more | ||
| + * separators. | ||
| + * | ||
| + * @return A non-null string, possibly empty. | ||
| + */ | ||
| + private String getCurrentPath() { | ||
| + final String s = extractTextChunk(); | ||
| + final int length = s.length(); | ||
| + | ||
| + int i = 0; | ||
| + | ||
| + while( i < length && !isWhitespace( s.charAt( i ) ) ) { | ||
| + i++; | ||
| + } | ||
| + | ||
| + return s.substring( 0, i ); | ||
| + } | ||
| + | ||
| + private <T> ObservableList<TreeItem<T>> getSiblings( | ||
| + final TreeItem<T> item ) { | ||
| + final TreeItem<T> parent = item.getParent(); | ||
| + return parent == null ? item.getChildren() : parent.getChildren(); | ||
| + } | ||
| + | ||
| + private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) { | ||
| + return getFirst( getSiblings( item ), item ); | ||
| + } | ||
| + | ||
| + private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) { | ||
| + return getLast( getSiblings( item ), item ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the caret position as an offset into the text. | ||
| + * | ||
| + * @return A value from 0 to the length of the text (minus one). | ||
| + */ | ||
| + private int getCurrentCaretPosition() { | ||
| + return getEditor().getCaretPosition(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the caret position within the current paragraph. | ||
| + * | ||
| + * @return A value from 0 to the length of the current paragraph. | ||
| + */ | ||
| + private int getCurrentCaretColumn() { | ||
| + return getEditor().getCaretColumn(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the last word from the path. | ||
| + * | ||
| + * @return The last token. | ||
| + */ | ||
| + private String getLastPathWord() { | ||
| + String path = getCurrentPath(); | ||
| + | ||
| + int i = path.indexOf( SEPARATOR ); | ||
| + | ||
| + while( i > 0 ) { | ||
| + path = path.substring( i + 1 ); | ||
| + i = path.indexOf( SEPARATOR ); | ||
| + } | ||
| + | ||
| + return path; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns text from the initial caret position until some arbitrarily long | ||
| + * number of characters. The number of characters extracted will be | ||
| + * getMaxVarLength, or fewer, depending on how many characters remain to be | ||
| + * extracted. The result from this method is trimmed to the first whitespace | ||
| + * character. | ||
| + * | ||
| + * @return A chunk of text that includes all the words representing a path, | ||
| + * and then some. | ||
| + */ | ||
| + private String extractTextChunk() { | ||
| + final StyledTextArea textArea = getEditor(); | ||
| + final int textBegan = getInitialCaretPosition(); | ||
| + final int remaining = textArea.getLength() - textBegan; | ||
| + final int textEnded = min( remaining, getMaxVarLength() ); | ||
| + | ||
| + return textArea.getText( textBegan, textEnded ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the node for the current path. | ||
| + */ | ||
| + private TreeItem<String> getCurrentNode() { | ||
| + return findNode( getCurrentPath() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Finds the node that most closely matches the given path. | ||
| + * | ||
| + * @param path The path that represents a node. | ||
| + * | ||
| + * @return The node for the path, or the root node if the path could not be | ||
| + * found, but never null. | ||
| + */ | ||
| + private TreeItem<String> findNode( final String path ) { | ||
| + return getDefinitionPane().findNode( path ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Finds the first leaf having a value that starts with the given text. | ||
| + * | ||
| + * @param text The text to find in the definition tree. | ||
| + * | ||
| + * @return The leaf that starts with the given text, or null if not found. | ||
| + */ | ||
| + private VariableTreeItem<String> findLeaf( final String text ) { | ||
| + return getDefinitionPane().findLeaf( text ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Used to ignore typed keys in favour of trapping pressed keys. | ||
| + * | ||
| + * @param e The key that was typed. | ||
| + */ | ||
| + private void vModeKeyTyped( KeyEvent e ) { | ||
| + e.consume(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Used to lazily initialize the keyboard map. | ||
| + * | ||
| + * @return Mappings for keyTyped and keyPressed. | ||
| + */ | ||
| + protected InputMap<InputEvent> createKeyboardMap() { | ||
| + return sequence( | ||
| + consume( keyTyped(), this::vModeKeyTyped ), | ||
| + consume( keyPressed(), this::vModeKeyPressed ) | ||
| + ); | ||
| + } | ||
| + | ||
| + private InputMap<InputEvent> getKeyboardMap() { | ||
| + if( this.keyboardMap == null ) { | ||
| + this.keyboardMap = createKeyboardMap(); | ||
| + } | ||
| + | ||
| + return this.keyboardMap; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Collapses the tree then expands and selects the given node. | ||
| + * | ||
| + * @param node The node to expand. | ||
| + */ | ||
| + private void expand( final TreeItem<String> node ) { | ||
| + final DefinitionPane pane = getDefinitionPane(); | ||
| + pane.collapse(); | ||
| + pane.expand( node ); | ||
| + pane.select( node ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns true iff the key code the user typed can be used as part of a YAML | ||
| + * variable name. | ||
| + * | ||
| + * @param keyEvent Keyboard key press event information. | ||
| + * | ||
| + * @return true The key is a value that can be inserted into the text. | ||
| + */ | ||
| + private boolean isVariableNameKey( final KeyEvent keyEvent ) { | ||
| + final KeyCode kc = keyEvent.getCode(); | ||
| + | ||
| + return (kc.isLetterKey() | ||
| + || kc.isDigitKey() | ||
| + || (keyEvent.isShiftDown() && kc == MINUS)) | ||
| + && !keyEvent.isControlDown(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Starts to capture user input events. | ||
| + */ | ||
| + private void vModeStart() { | ||
| + addEventListener( getKeyboardMap() ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Restores capturing of user input events to the previous event listener. | ||
| + * Also asks the processing chain to modify the variable text into a | ||
| + * machine-readable variable based on the format required by the file type. | ||
| + * For example, a Markdown file (.md) will substitute a $VAR$ name while an R | ||
| + * file (.Rmd, .Rxml) will use `r#xVAR`. | ||
| + */ | ||
| + private void vModeStop() { | ||
| + removeEventListener( getKeyboardMap() ); | ||
| + } | ||
| + | ||
| + private VariableDecorator getVariableDecorator() { | ||
| + return new YamlVariableDecorator(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the index where the two strings diverge. | ||
| + * | ||
| + * @param s1 The string that could be a substring of s2, null allowed. | ||
| + * @param s2 The string that could be a substring of s1, null allowed. | ||
| + * | ||
| + * @return NO_DIFFERENCE if the strings are the same, otherwise the index | ||
| + * where they differ. | ||
| + */ | ||
| + @SuppressWarnings( "StringEquality" ) | ||
| + private int difference( final CharSequence s1, final CharSequence s2 ) { | ||
| + if( s1 == s2 ) { | ||
| + return NO_DIFFERENCE; | ||
| + } | ||
| + | ||
| + if( s1 == null || s2 == null ) { | ||
| + return 0; | ||
| + } | ||
| + | ||
| + int i = 0; | ||
| + final int limit = min( s1.length(), s2.length() ); | ||
| + | ||
| + while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) { | ||
| + i++; | ||
| + } | ||
| + | ||
| + // If one string was shorter than the other, that's where they differ. | ||
| + return i; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to the file editor pane, and, ultimately, to its text area. | ||
| + */ | ||
| + private <T extends Event, U extends T> void addEventListener( | ||
| + final EventPattern<? super T, ? extends U> event, | ||
| + final Consumer<? super U> consumer ) { | ||
| + getFileEditorPane().addEventListener( event, consumer ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to the file editor pane, and, ultimately, to its text area. | ||
| + * | ||
| + * @param map The map of methods to events. | ||
| + */ | ||
| + private void addEventListener( final InputMap<InputEvent> map ) { | ||
| + getFileEditorPane().addEventListener( map ); | ||
| + } | ||
| + | ||
| + private void removeEventListener( final InputMap<InputEvent> map ) { | ||
| + getFileEditorPane().removeEventListener( map ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the position of the caret when variable mode editing was requested. | ||
| + * | ||
| + * @return The variable mode caret position. | ||
| + */ | ||
| + private int getInitialCaretPosition() { | ||
| + return this.initialCaretPosition; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Sets the position of the caret when variable mode editing was requested. | ||
| + * Stores the current position because only the text that comes afterwards is | ||
| + * a suitable variable reference. | ||
| + * | ||
| + * @return The variable mode caret position. | ||
| + */ | ||
| + private void setInitialCaretPosition() { | ||
| + this.initialCaretPosition = getEditor().getCaretPosition(); | ||
| + } | ||
| + | ||
| + private StyledTextArea getEditor() { | ||
| + return getFileEditorPane().getEditor(); | ||
| + } | ||
| + | ||
| + public FileEditorTabPane getFileEditorPane() { | ||
| + return this.fileEditorPane; | ||
| + } | ||
| + | ||
| + private void setFileEditorPane( final FileEditorTabPane fileEditorPane ) { | ||
| this.fileEditorPane = fileEditorPane; | ||
| } |
| +/* | ||
| + * Copyright 2016 Karl Tauber and 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 static com.scrivenvar.Messages.get; | ||
| +import com.scrivenvar.predicates.files.FileTypePredicate; | ||
| +import com.scrivenvar.service.Options; | ||
| +import com.scrivenvar.service.Settings; | ||
| +import com.scrivenvar.service.events.AlertMessage; | ||
| +import com.scrivenvar.service.events.AlertService; | ||
| +import static com.scrivenvar.service.events.AlertService.NO; | ||
| +import static com.scrivenvar.service.events.AlertService.YES; | ||
| +import com.scrivenvar.util.Utils; | ||
| +import java.io.File; | ||
| +import java.nio.file.Path; | ||
| +import java.util.ArrayList; | ||
| +import java.util.List; | ||
| +import java.util.function.Consumer; | ||
| +import java.util.prefs.Preferences; | ||
| +import java.util.stream.Collectors; | ||
| +import javafx.beans.property.ReadOnlyBooleanProperty; | ||
| +import javafx.beans.property.ReadOnlyBooleanWrapper; | ||
| +import javafx.beans.property.ReadOnlyObjectProperty; | ||
| +import javafx.beans.property.ReadOnlyObjectWrapper; | ||
| +import javafx.beans.value.ChangeListener; | ||
| +import javafx.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.scene.control.TabPane.TabClosingPolicy; | ||
| +import javafx.scene.input.InputEvent; | ||
| +import javafx.stage.FileChooser; | ||
| +import javafx.stage.FileChooser.ExtensionFilter; | ||
| +import javafx.stage.Window; | ||
| +import org.fxmisc.richtext.StyledTextArea; | ||
| +import org.fxmisc.wellbehaved.event.EventPattern; | ||
| +import org.fxmisc.wellbehaved.event.InputMap; | ||
| + | ||
| +/** | ||
| + * Tab pane for file editors. | ||
| + * | ||
| + * @author Karl Tauber and White Magic Software, Ltd. | ||
| + */ | ||
| +public class FileEditorTabPane extends TabPane implements ChangeListener<Tab> { | ||
| + | ||
| + private final static String FILTER_PREFIX = "Dialog.file.choose.filter"; | ||
| + | ||
| + private final Options options = Services.load( Options.class ); | ||
| + private final Settings settings = Services.load( Settings.class ); | ||
| + private final AlertService alertService = Services.load( AlertService.class ); | ||
| + | ||
| + private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>(); | ||
| + private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper(); | ||
| + | ||
| + public FileEditorTabPane() { | ||
| + final ObservableList<Tab> tabs = getTabs(); | ||
| + | ||
| + setFocusTraversable( false ); | ||
| + setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | ||
| + | ||
| + // Observe the tab so that when a new tab is opened or selected, | ||
| + // a notification is kicked off. | ||
| + getSelectionModel().selectedItemProperty().addListener( this ); | ||
| + | ||
| + // update anyFileEditorModified property | ||
| + final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> { | ||
| + for( final Tab tab : tabs ) { | ||
| + if( ((FileEditorTab)tab.getUserData()).isModified() ) { | ||
| + this.anyFileEditorModified.set( true ); | ||
| + break; | ||
| + } | ||
| + } | ||
| + }; | ||
| + | ||
| + tabs.addListener( (ListChangeListener<Tab>)c -> { | ||
| + while( c.next() ) { | ||
| + if( c.wasAdded() ) { | ||
| + c.getAddedSubList().stream().forEach( (tab) -> { | ||
| + ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener ); | ||
| + } ); | ||
| + } else if( c.wasRemoved() ) { | ||
| + c.getRemoved().stream().forEach( (tab) -> { | ||
| + ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener ); | ||
| + } ); | ||
| + } | ||
| + } | ||
| + | ||
| + // Changes in the tabs may also change anyFileEditorModified property | ||
| + // (e.g. closed modified file) | ||
| + modifiedListener.changed( null, null, null ); | ||
| + } ); | ||
| + | ||
| + // re-open files | ||
| + restoreState(); | ||
| + } | ||
| + | ||
| + public <T extends Event, U extends T> void addEventListener( | ||
| + final EventPattern<? super T, ? extends U> event, | ||
| + final Consumer<? super U> consumer ) { | ||
| + getActiveFileEditor().addEventListener( event, consumer ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Delegates to the active file editor pane, and, ultimately, to its text | ||
| + * area. | ||
| + * | ||
| + * @param map The map of methods to events. | ||
| + */ | ||
| + public void addEventListener( final InputMap<InputEvent> map ) { | ||
| + getActiveFileEditor().addEventListener( map ); | ||
| + } | ||
| + | ||
| + public void removeEventListener( final InputMap<InputEvent> map ) { | ||
| + getActiveFileEditor().removeEventListener( map ); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void changed( | ||
| + final ObservableValue<? extends Tab> observable, | ||
| + final Tab oldTab, | ||
| + final Tab newTab ) { | ||
| + | ||
| + if( newTab != null ) { | ||
| + final FileEditorTab tab = (FileEditorTab)newTab; | ||
| + | ||
| + this.activeFileEditor.set( (FileEditorTab)newTab.getUserData() ); | ||
| + } | ||
| + } | ||
| + | ||
| + Node getNode() { | ||
| + return this; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Allows clients to manipulate the editor content directly. | ||
| + * | ||
| + * @return The text area for the active file editor. | ||
| + */ | ||
| + public StyledTextArea getEditor() { | ||
| + return getActiveFileEditor().getEditorPane().getEditor(); | ||
| + } | ||
| + | ||
| + public FileEditorTab getActiveFileEditor() { | ||
| + return this.activeFileEditor.get(); | ||
| + } | ||
| + | ||
| + ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | ||
| + return this.activeFileEditor.getReadOnlyProperty(); | ||
| + } | ||
| + | ||
| + ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | ||
| + return this.anyFileEditorModified.getReadOnlyProperty(); | ||
| + } | ||
| + | ||
| + private FileEditorTab createFileEditor( Path path ) { | ||
| + final FileEditorTab fileEditor = new FileEditorTab( path ); | ||
| + | ||
| + fileEditor.setOnCloseRequest( e -> { | ||
| + if( !canCloseEditor( fileEditor ) ) { | ||
| + e.consume(); | ||
| + } | ||
| + } ); | ||
| + | ||
| + return fileEditor; | ||
| + } | ||
| + | ||
| + FileEditorTab newEditor() { | ||
| + final FileEditorTab tab = createFileEditor( null ); | ||
| + | ||
| + getTabs().add( tab ); | ||
| + getSelectionModel().select( tab ); | ||
| + return tab; | ||
| + } | ||
| + | ||
| + List<FileEditorTab> openFileDialog() { | ||
| + final FileChooser dialog | ||
| + = createFileChooser( get( "Dialog.file.choose.open.title" ) ); | ||
| + final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | ||
| + | ||
| + return (files != null && !files.isEmpty()) | ||
| + ? openFiles( files ) | ||
| + : new ArrayList<>(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Opens the files into new editors, unless one of those files was a | ||
| + * definition file. The definition file is loaded into the definition pane, | ||
| + * but only the first one selected (multiple definition files will result in a | ||
| + * warning). | ||
| + * | ||
| + * @param files The list of non-definition files that the were requested to | ||
| + * open. | ||
| + * | ||
| + * @return A list of files that can be opened in text editors. | ||
| + */ | ||
| + private List<FileEditorTab> openFiles( final List<File> files ) { | ||
| + final List<FileEditorTab> openedEditors = new ArrayList<>(); | ||
| + | ||
| + final FileTypePredicate predicate | ||
| + = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() ); | ||
| + | ||
| + // The user might have opened muliple definitions files. These will | ||
| + // be discarded from the text editable files. | ||
| + final List<File> definitions | ||
| + = files.stream().filter( predicate ).collect( Collectors.toList() ); | ||
| + | ||
| + // Create a modifiable list to remove any definition files that were | ||
| + // opened. | ||
| + final List<File> editors = new ArrayList<>( files ); | ||
| + editors.removeAll( definitions ); | ||
| + | ||
| + // If there are any editor-friendly files opened (e.g,. Markdown, XML), then | ||
| + // open them up in new tabs. | ||
| + if( editors.size() > 0 ) { | ||
| + saveLastDirectory( editors.get( 0 ) ); | ||
| + openedEditors.addAll( openEditors( editors, 0 ) ); | ||
| + } | ||
| + | ||
| + if( definitions.size() > 0 ) { | ||
| + openDefinition( definitions.get( 0 ) ); | ||
| + } | ||
| + | ||
| + return openedEditors; | ||
| + } | ||
| + | ||
| + private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) { | ||
| + final List<FileEditorTab> editors = new ArrayList<>(); | ||
| + final List<Tab> tabs = getTabs(); | ||
| + | ||
| + // Close single unmodified "Untitled" tab. | ||
| + if( tabs.size() == 1 ) { | ||
| + final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData()); | ||
| + | ||
| + if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | ||
| + closeEditor( fileEditor, false ); | ||
| + } | ||
| + } | ||
| + | ||
| + for( int i = 0; i < files.size(); i++ ) { | ||
| + Path path = files.get( i ).toPath(); | ||
| + | ||
| + // Check whether file is already opened. | ||
| + FileEditorTab fileEditor = findEditor( path ); | ||
| + | ||
| + if( fileEditor == null ) { | ||
| + fileEditor = createFileEditor( path ); | ||
| + getTabs().add( fileEditor ); | ||
| + editors.add( fileEditor ); | ||
| + } | ||
| + | ||
| + // Select first file. | ||
| + if( i == activeIndex ) { | ||
| + getSelectionModel().select( fileEditor ); | ||
| + } | ||
| + } | ||
| + | ||
| + return editors; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Called when the user has opened a definition file (using the file open | ||
| + * dialog box). This will replace the current set of definitions for the | ||
| + * active tab. | ||
| + * | ||
| + * @param definition The file to open. | ||
| + */ | ||
| + private void openDefinition( final File definition ) { | ||
| + System.out.println( "open definition file: " + definition.toString() ); | ||
| + } | ||
| + | ||
| + boolean saveEditor( final FileEditorTab fileEditor ) { | ||
| + if( fileEditor == null || !fileEditor.isModified() ) { | ||
| + return true; | ||
| + } | ||
| + | ||
| + if( fileEditor.getPath() == null ) { | ||
| + getSelectionModel().select( fileEditor ); | ||
| + | ||
| + final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) ); | ||
| + final File file = fileChooser.showSaveDialog( getWindow() ); | ||
| + if( file == null ) { | ||
| + return false; | ||
| + } | ||
| + | ||
| + saveLastDirectory( file ); | ||
| + fileEditor.setPath( file.toPath() ); | ||
| + } | ||
| + | ||
| + return fileEditor.save(); | ||
| + } | ||
| + | ||
| + boolean saveAllEditors() { | ||
| + FileEditorTab[] allEditors = getAllEditors(); | ||
| + | ||
| + boolean success = true; | ||
| + for( FileEditorTab fileEditor : allEditors ) { | ||
| + if( !saveEditor( fileEditor ) ) { | ||
| + success = false; | ||
| + } | ||
| + } | ||
| + | ||
| + return success; | ||
| + } | ||
| + | ||
| + boolean canCloseEditor( final FileEditorTab fileEditor ) { | ||
| + if( !fileEditor.isModified() ) { | ||
| + return true; | ||
| + } | ||
| + | ||
| + final AlertMessage message = getAlertService().createAlertMessage( | ||
| + Messages.get( "Alert.file.close.title" ), | ||
| + Messages.get( "Alert.file.close.text" ), | ||
| + fileEditor.getText() | ||
| + ); | ||
| + | ||
| + final Alert alert = getAlertService().createAlertConfirmation( message ); | ||
| + final ButtonType response = alert.showAndWait().get(); | ||
| + | ||
| + return response == YES ? saveEditor( fileEditor ) : response == NO; | ||
| + } | ||
| + | ||
| + private AlertService getAlertService() { | ||
| + return this.alertService; | ||
| + } | ||
| + | ||
| + boolean closeEditor( FileEditorTab fileEditor, boolean save ) { | ||
| + if( fileEditor == null ) { | ||
| + return true; | ||
| + } | ||
| + | ||
| + final Tab tab = fileEditor; | ||
| + | ||
| + if( save ) { | ||
| + Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | ||
| + Event.fireEvent( tab, event ); | ||
| + if( event.isConsumed() ) { | ||
| + return false; | ||
| + } | ||
| + } | ||
| + | ||
| + getTabs().remove( tab ); | ||
| + if( tab.getOnClosed() != null ) { | ||
| + Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | ||
| + } | ||
| + | ||
| + return true; | ||
| + } | ||
| + | ||
| + boolean closeAllEditors() { | ||
| + FileEditorTab[] allEditors = getAllEditors(); | ||
| + FileEditorTab activeEditor = activeFileEditor.get(); | ||
| + | ||
| + // 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; | ||
| + } | ||
| + | ||
| + // save modified tabs | ||
| + for( int i = 0; i < allEditors.length; i++ ) { | ||
| + 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; | ||
| + } | ||
| + } | ||
| + | ||
| + saveState( allEditors, activeEditor ); | ||
| + | ||
| + return getTabs().isEmpty(); | ||
| + } | ||
| + | ||
| + private FileEditorTab[] getAllEditors() { | ||
| + final ObservableList<Tab> tabs = getTabs(); | ||
| + final FileEditorTab[] allEditors = new FileEditorTab[ tabs.size() ]; | ||
| + final int length = tabs.size(); | ||
| + | ||
| + for( int i = 0; i < length; i++ ) { | ||
| + allEditors[ i ] = (FileEditorTab)tabs.get( i ).getUserData(); | ||
| + } | ||
| + | ||
| + return allEditors; | ||
| + } | ||
| + | ||
| + private FileEditorTab findEditor( Path path ) { | ||
| + for( final Tab tab : getTabs() ) { | ||
| + final FileEditorTab fileEditor = (FileEditorTab)tab.getUserData(); | ||
| + | ||
| + if( path.equals( fileEditor.getPath() ) ) { | ||
| + return fileEditor; | ||
| + } | ||
| + } | ||
| + | ||
| + return null; | ||
| + } | ||
| + | ||
| + private Settings getSettings() { | ||
| + return this.settings; | ||
| + } | ||
| + | ||
| + private FileChooser createFileChooser( String title ) { | ||
| + final FileChooser fileChooser = new FileChooser(); | ||
| + | ||
| + fileChooser.setTitle( title ); | ||
| + fileChooser.getExtensionFilters().addAll( | ||
| + createExtensionFilters() ); | ||
| + | ||
| + final String lastDirectory = getState().get( "lastDirectory", null ); | ||
| + File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | ||
| + | ||
| + if( !file.isDirectory() ) { | ||
| + file = new File( "." ); | ||
| + } | ||
| + | ||
| + fileChooser.setInitialDirectory( file ); | ||
| + return fileChooser; | ||
| + } | ||
| + | ||
| + private List<ExtensionFilter> createExtensionFilters() { | ||
| + final List<ExtensionFilter> list = new ArrayList<>(); | ||
| + | ||
| + // TODO: Return a list of all properties that match the filter prefix. | ||
| + // This will allow dynamic filters to be added and removed just by | ||
| + // updating the properties file. | ||
| + list.add( createExtensionFilter( "markdown" ) ); | ||
| + list.add( createExtensionFilter( "definition" ) ); | ||
| + list.add( createExtensionFilter( "xml" ) ); | ||
| + list.add( createExtensionFilter( "all" ) ); | ||
| + return list; | ||
| + } | ||
| + | ||
| + private ExtensionFilter createExtensionFilter( final String filetype ) { | ||
| + final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype ); | ||
| + final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype ); | ||
| + | ||
| + return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | ||
| + } | ||
| + | ||
| + private List<String> getExtensions( final String key ) { | ||
| + return getStringSettingList( key ); | ||
| + } | ||
| + | ||
| + private List<String> getStringSettingList( String key ) { | ||
| + return getStringSettingList( key, null ); | ||
| + } | ||
| + | ||
| + private List<String> getStringSettingList( String key, List<String> values ) { | ||
| + return getSettings().getStringSettingList( key, values ); | ||
| + } | ||
| + | ||
| + private void saveLastDirectory( File file ) { | ||
| + getState().put( "lastDirectory", file.getParent() ); | ||
| + } | ||
| + | ||
| + private void restoreState() { | ||
| + int activeIndex = 0; | ||
| + | ||
| + final Preferences state = getState(); | ||
| + final String[] fileNames = Utils.getPrefsStrings( state, "file" ); | ||
| + final String activeFileName = state.get( "activeFile", null ); | ||
| + | ||
| + final ArrayList<File> files = new ArrayList<>( fileNames.length ); | ||
| + | ||
| + for( final String fileName : fileNames ) { | ||
| + final File file = new File( fileName ); | ||
| + | ||
| + if( file.exists() ) { | ||
| + files.add( file ); | ||
| + | ||
| + if( fileName.equals( activeFileName ) ) { | ||
| + activeIndex = files.size() - 1; | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + if( files.isEmpty() ) { | ||
| + newEditor(); | ||
| + return; | ||
| + } | ||
| + | ||
| + openEditors( files, activeIndex ); | ||
| + } | ||
| + | ||
| + private void saveState( final FileEditorTab[] allEditors, final FileEditorTab activeEditor ) { | ||
| + final List<String> fileNames = new ArrayList<>( allEditors.length ); | ||
| + | ||
| + for( final FileEditorTab fileEditor : allEditors ) { | ||
| + if( fileEditor.getPath() != null ) { | ||
| + fileNames.add( fileEditor.getPath().toString() ); | ||
| + } | ||
| + } | ||
| + | ||
| + final Preferences state = getState(); | ||
| + Utils.putPrefsStrings( state, "file", fileNames.toArray( new String[ fileNames.size() ] ) ); | ||
| + | ||
| + if( activeEditor != null && activeEditor.getPath() != null ) { | ||
| + state.put( "activeFile", activeEditor.getPath().toString() ); | ||
| + } else { | ||
| + state.remove( "activeFile" ); | ||
| + } | ||
| + } | ||
| + | ||
| + private Window getWindow() { | ||
| + return getScene().getWindow(); | ||
| + } | ||
| + | ||
| + protected Options getOptions() { | ||
| + return this.options; | ||
| + } | ||
| + | ||
| + protected Preferences getState() { | ||
| + return getOptions().getState(); | ||
| + } | ||
| + | ||
| +} | ||
| import javafx.beans.property.BooleanProperty; | ||
| import javafx.beans.property.SimpleBooleanProperty; | ||
| -import javafx.beans.value.ChangeListener; | ||
| -import javafx.beans.value.ObservableBooleanValue; | ||
| -import javafx.event.Event; | ||
| -import javafx.scene.Node; | ||
| -import javafx.scene.Scene; | ||
| -import javafx.scene.control.Alert; | ||
| -import javafx.scene.control.Alert.AlertType; | ||
| -import javafx.scene.control.Menu; | ||
| -import javafx.scene.control.MenuBar; | ||
| -import javafx.scene.control.SplitPane; | ||
| -import javafx.scene.control.ToolBar; | ||
| -import javafx.scene.control.TreeView; | ||
| -import javafx.scene.image.Image; | ||
| -import javafx.scene.image.ImageView; | ||
| -import static javafx.scene.input.KeyCode.ESCAPE; | ||
| -import javafx.scene.input.KeyEvent; | ||
| -import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED; | ||
| -import static javafx.scene.input.KeyEvent.KEY_PRESSED; | ||
| -import javafx.scene.layout.BorderPane; | ||
| -import javafx.scene.layout.VBox; | ||
| -import javafx.stage.Window; | ||
| -import javafx.stage.WindowEvent; | ||
| - | ||
| -/** | ||
| - * Main window containing a tab pane in the center for file editors. | ||
| - * | ||
| - * @author Karl Tauber and White Magic Software, Ltd. | ||
| - */ | ||
| -public class MainWindow { | ||
| - | ||
| - private final Options options = Services.load( Options.class ); | ||
| - | ||
| - private Scene scene; | ||
| - | ||
| - private TreeView<String> treeView; | ||
| - private FileEditorPane fileEditorPane; | ||
| - private DefinitionPane definitionPane; | ||
| - | ||
| - private VariableNameInjector variableNameInjector; | ||
| - | ||
| - private YamlTreeAdapter yamlTreeAdapter; | ||
| - private YamlParser yamlParser; | ||
| - | ||
| - private MenuBar menuBar; | ||
| - | ||
| - public MainWindow() { | ||
| - initLayout(); | ||
| - initVariableNameInjector(); | ||
| - } | ||
| - | ||
| - private void initLayout() { | ||
| - final SplitPane splitPane = new SplitPane( | ||
| - getDefinitionPane().getNode(), | ||
| - getFileEditorPane().getNode() ); | ||
| - splitPane.setDividerPositions( | ||
| - getFloat( K_PANE_SPLIT_DEFINITION, .05f ), | ||
| - getFloat( K_PANE_SPLIT_EDITOR, .95f ) ); | ||
| - | ||
| - // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restore-splitpane.html | ||
| - BorderPane borderPane = new BorderPane(); | ||
| - borderPane.setPrefSize( 1024, 800 ); | ||
| - borderPane.setTop( createMenuBar() ); | ||
| - borderPane.setCenter( splitPane ); | ||
| - | ||
| - setScene( new Scene( borderPane ) ); | ||
| - getScene().getStylesheets().add( Constants.STYLESHEET_PREVIEW ); | ||
| - getScene().windowProperty().addListener( | ||
| - (observable, oldWindow, newWindow) -> { | ||
| - newWindow.setOnCloseRequest( e -> { | ||
| - if( !getFileEditorPane().closeAllEditors() ) { | ||
| - e.consume(); | ||
| - } | ||
| - } ); | ||
| - | ||
| - // Workaround JavaFX bug: deselect menubar if window loses focus. | ||
| - newWindow.focusedProperty().addListener( | ||
| - (obs, oldFocused, newFocused) -> { | ||
| - if( !newFocused ) { | ||
| - // Send an ESC key event to the menubar | ||
| - this.menuBar.fireEvent( | ||
| - new KeyEvent( | ||
| - KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE, | ||
| - false, false, false, false ) ); | ||
| - } | ||
| - } ); | ||
| - } ); | ||
| - } | ||
| - | ||
| - private void initVariableNameInjector() { | ||
| - setVariableNameInjector( new VariableNameInjector( | ||
| - getFileEditorPane(), | ||
| - getDefinitionPane() ) | ||
| - ); | ||
| - } | ||
| - | ||
| - private Window getWindow() { | ||
| - return getScene().getWindow(); | ||
| - } | ||
| - | ||
| - public Scene getScene() { | ||
| - return this.scene; | ||
| - } | ||
| - | ||
| - private void setScene( Scene scene ) { | ||
| - this.scene = scene; | ||
| - } | ||
| - | ||
| - private Node createMenuBar() { | ||
| - final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull(); | ||
| - | ||
| - // File actions | ||
| - Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() ); | ||
| - Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() ); | ||
| - Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull ); | ||
| - Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull ); | ||
| - Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(), | ||
| - createActiveBooleanProperty( FileEditor::modifiedProperty ).not() ); | ||
| - Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(), | ||
| - Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | ||
| - Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() ); | ||
| - | ||
| - // Edit actions | ||
| - Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO, | ||
| - e -> getActiveEditor().undo(), | ||
| - createActiveBooleanProperty( FileEditor::canUndoProperty ).not() ); | ||
| - Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT, | ||
| - e -> getActiveEditor().redo(), | ||
| - createActiveBooleanProperty( FileEditor::canRedoProperty ).not() ); | ||
| - | ||
| - // Insert actions | ||
| - Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD, | ||
| - e -> getActiveEditor().surroundSelection( "**", "**" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | ||
| - e -> getActiveEditor().surroundSelection( "*", "*" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | ||
| - e -> getActiveEditor().surroundSelection( "~~", "~~" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac | ||
| - e -> getActiveEditor().surroundSelection( "\n\n> ", "" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE, | ||
| - e -> getActiveEditor().surroundSelection( "`", "`" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT, | ||
| - e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ), | ||
| - activeFileEditorIsNull ); | ||
| - | ||
| - Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK, | ||
| - e -> getActiveEditor().insertLink(), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT, | ||
| - e -> getActiveEditor().insertImage(), | ||
| - activeFileEditorIsNull ); | ||
| - | ||
| - final Action[] headers = new Action[ 6 ]; | ||
| - | ||
| - // Insert header actions (H1 ... H6) | ||
| - for( int i = 1; i <= 6; i++ ) { | ||
| - final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | ||
| - final String markup = String.format( "\n\n%s ", hashes ); | ||
| - final String text = Messages.get( "Main.menu.insert.header_" + i ); | ||
| - final String accelerator = "Shortcut+" + i; | ||
| - final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" ); | ||
| - | ||
| - headers[ i - 1 ] = new Action( text, accelerator, HEADER, | ||
| - e -> getActiveEditor().surroundSelection( markup, "", prompt ), | ||
| - activeFileEditorIsNull ); | ||
| - } | ||
| - | ||
| - Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | ||
| - e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | ||
| - e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | ||
| - activeFileEditorIsNull ); | ||
| - Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | ||
| - e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | ||
| - activeFileEditorIsNull ); | ||
| - | ||
| - // Tools actions | ||
| - Action toolsOptionsAction = new Action( Messages.get( "Main.menu.tools.options" ), "Shortcut+,", null, e -> toolsOptions() ); | ||
| - | ||
| - // Help actions | ||
| - Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | ||
| - | ||
| - //---- MenuBar ---- | ||
| - Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ), | ||
| - fileNewAction, | ||
| - fileOpenAction, | ||
| - null, | ||
| - fileCloseAction, | ||
| - fileCloseAllAction, | ||
| - null, | ||
| - fileSaveAction, | ||
| - fileSaveAllAction, | ||
| - null, | ||
| - fileExitAction ); | ||
| - | ||
| - Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ), | ||
| - editUndoAction, | ||
| - editRedoAction ); | ||
| - | ||
| - Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ), | ||
| - insertBoldAction, | ||
| - insertItalicAction, | ||
| - insertStrikethroughAction, | ||
| - insertBlockquoteAction, | ||
| - insertCodeAction, | ||
| - insertFencedCodeBlockAction, | ||
| - null, | ||
| - insertLinkAction, | ||
| - insertImageAction, | ||
| - null, | ||
| - headers[ 0 ], | ||
| - headers[ 1 ], | ||
| - headers[ 2 ], | ||
| - headers[ 3 ], | ||
| - headers[ 4 ], | ||
| - headers[ 5 ], | ||
| - null, | ||
| - insertUnorderedListAction, | ||
| - insertOrderedListAction, | ||
| - insertHorizontalRuleAction ); | ||
| - | ||
| - Menu toolsMenu = ActionUtils.createMenu( Messages.get( "Main.menu.tools" ), | ||
| - toolsOptionsAction ); | ||
| - | ||
| - Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ), | ||
| - helpAboutAction ); | ||
| - | ||
| - menuBar = new MenuBar( fileMenu, editMenu, insertMenu, toolsMenu, helpMenu ); | ||
| - | ||
| - //---- ToolBar ---- | ||
| - ToolBar toolBar = ActionUtils.createToolBar( | ||
| - fileNewAction, | ||
| - fileOpenAction, | ||
| - fileSaveAction, | ||
| - null, | ||
| - editUndoAction, | ||
| - editRedoAction, | ||
| - null, | ||
| - insertBoldAction, | ||
| - insertItalicAction, | ||
| - insertBlockquoteAction, | ||
| - insertCodeAction, | ||
| - insertFencedCodeBlockAction, | ||
| - null, | ||
| - insertLinkAction, | ||
| - insertImageAction, | ||
| - null, | ||
| - headers[ 0 ], | ||
| - null, | ||
| - insertUnorderedListAction, | ||
| - insertOrderedListAction ); | ||
| - | ||
| - return new VBox( menuBar, toolBar ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Creates a boolean property that is bound to another boolean value of the | ||
| - * active editor. | ||
| - */ | ||
| - private BooleanProperty createActiveBooleanProperty( | ||
| - final Function<FileEditor, ObservableBooleanValue> func ) { | ||
| - | ||
| - final BooleanProperty b = new SimpleBooleanProperty(); | ||
| - final FileEditor fileEditor = getActiveFileEditor(); | ||
| - | ||
| - if( fileEditor != null ) { | ||
| - b.bind( func.apply( fileEditor ) ); | ||
| - } | ||
| - | ||
| - getFileEditorPane().activeFileEditorProperty().addListener( | ||
| - (observable, oldFileEditor, newFileEditor) -> { | ||
| - b.unbind(); | ||
| - | ||
| - if( newFileEditor != null ) { | ||
| - b.bind( func.apply( newFileEditor ) ); | ||
| - } else { | ||
| - b.set( false ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - return b; | ||
| - } | ||
| - | ||
| - //---- File actions ------------------------------------------------------- | ||
| - private void fileNew() { | ||
| - getFileEditorPane().newEditor(); | ||
| - } | ||
| - | ||
| - private void fileOpen() { | ||
| - getFileEditorPane().openFileDialog(); | ||
| - } | ||
| - | ||
| - private void fileClose() { | ||
| - getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | ||
| - } | ||
| - | ||
| - private void fileCloseAll() { | ||
| - getFileEditorPane().closeAllEditors(); | ||
| - } | ||
| - | ||
| - private void fileSave() { | ||
| - getFileEditorPane().saveEditor( getActiveFileEditor() ); | ||
| - } | ||
| - | ||
| - private void fileSaveAll() { | ||
| - getFileEditorPane().saveAllEditors(); | ||
| - } | ||
| - | ||
| - private void fileExit() { | ||
| - final Window window = getWindow(); | ||
| - Event.fireEvent( window, | ||
| - new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) ); | ||
| - } | ||
| - | ||
| - //---- Tools actions ------------------------------------------------------ | ||
| - private void toolsOptions() { | ||
| - new OptionsDialog( getWindow() ).showAndWait(); | ||
| - } | ||
| - | ||
| - //---- Help actions ------------------------------------------------------- | ||
| - private void helpAbout() { | ||
| - Alert alert = new Alert( AlertType.INFORMATION ); | ||
| - alert.setTitle( Messages.get( "Dialog.about.title" ) ); | ||
| - alert.setHeaderText( Messages.get( "Dialog.about.header" ) ); | ||
| - alert.setContentText( Messages.get( "Dialog.about.content" ) ); | ||
| - alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) ); | ||
| - alert.initOwner( getWindow() ); | ||
| - | ||
| - alert.showAndWait(); | ||
| - } | ||
| - | ||
| - private FileEditorPane getFileEditorPane() { | ||
| - if( this.fileEditorPane == null ) { | ||
| - this.fileEditorPane = createFileEditorPane(); | ||
| - } | ||
| - | ||
| - return this.fileEditorPane; | ||
| - } | ||
| - | ||
| - private FileEditorPane createFileEditorPane() { | ||
| - final FileEditorPane pane = new FileEditorPane( this ); | ||
| - | ||
| - // Load file and create UI when the tab becomes visible the first time. | ||
| - final HTMLPreviewPane previewPane = pane.getActiveFileEditor().getPreviewPane(); | ||
| - | ||
| - // TODO: Change this to use a factory based on the filename extension. | ||
| - // See: https://github.com/DaveJarvis/scrivenvar/issues/17 | ||
| - // See: https://github.com/DaveJarvis/scrivenvar/issues/18 | ||
| - final Processor<String> hpp = new HTMLPreviewProcessor( previewPane ); | ||
| - final Processor<String> mp = new MarkdownProcessor( hpp ); | ||
| - final Processor<String> vnp = new VariableProcessor( mp, getResolvedMap() ); | ||
| - final ChangeListener<String> tp = new TextChangeProcessor( vnp ); | ||
| - | ||
| - pane.getActiveFileEditor().addChangeListener( tp ); | ||
| - | ||
| - return pane; | ||
| - } | ||
| - | ||
| - private MarkdownEditorPane getActiveEditor() { | ||
| - return getActiveFileEditor().getEditorPane(); | ||
| - } | ||
| - | ||
| - private FileEditor getActiveFileEditor() { | ||
| - return getFileEditorPane().getActiveFileEditor(); | ||
| - } | ||
| - | ||
| - protected DefinitionPane createDefinitionPane() { | ||
| - return new DefinitionPane( getTreeView() ); | ||
| - } | ||
| - | ||
| - private DefinitionPane getDefinitionPane() { | ||
| - if( this.definitionPane == null ) { | ||
| - this.definitionPane = createDefinitionPane(); | ||
| - } | ||
| - | ||
| - return this.definitionPane; | ||
| - } | ||
| - | ||
| - public MenuBar getMenuBar() { | ||
| - return menuBar; | ||
| - } | ||
| - | ||
| - public void setMenuBar( MenuBar menuBar ) { | ||
| - this.menuBar = menuBar; | ||
| - } | ||
| - | ||
| - public VariableNameInjector getVariableNameInjector() { | ||
| - return this.variableNameInjector; | ||
| - } | ||
| - | ||
| - public void setVariableNameInjector( VariableNameInjector variableNameInjector ) { | ||
| - this.variableNameInjector = variableNameInjector; | ||
| - } | ||
| - | ||
| - private float getFloat( final String key, final float defaultValue ) { | ||
| - return getPreferences().getFloat( key, defaultValue ); | ||
| - } | ||
| - | ||
| - private Preferences getPreferences() { | ||
| - return getOptions().getState(); | ||
| - } | ||
| - | ||
| - private Options getOptions() { | ||
| - return this.options; | ||
| - } | ||
| - | ||
| - private synchronized TreeView<String> getTreeView() throws RuntimeException { | ||
| - if( this.treeView == null ) { | ||
| - try { | ||
| - this.treeView = createTreeView(); | ||
| - } catch( IOException ex ) { | ||
| - | ||
| - // TODO: Pop an error message. | ||
| - throw new RuntimeException( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - return this.treeView; | ||
| - } | ||
| - | ||
| - private InputStream asStream( final String resource ) { | ||
| - return getClass().getResourceAsStream( resource ); | ||
| - } | ||
| - | ||
| - private TreeView<String> createTreeView() throws IOException { | ||
| - // TODO: Associate variable file with path to current file. | ||
| - return getYamlTreeAdapter().adapt( | ||
| - asStream( "/com/scrivenvar/variables.yaml" ), | ||
| - get( "Pane.defintion.node.root.title" ) | ||
| - ); | ||
| - } | ||
| - | ||
| - private Map<String, String> getResolvedMap() { | ||
| - return getYamlParser().createResolvedMap(); | ||
| - } | ||
| - | ||
| - private YamlTreeAdapter getYamlTreeAdapter() { | ||
| - if( this.yamlTreeAdapter == null ) { | ||
| - setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) ); | ||
| - } | ||
| - | ||
| - return this.yamlTreeAdapter; | ||
| - } | ||
| - | ||
| - private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) { | ||
| - this.yamlTreeAdapter = yamlTreeAdapter; | ||
| - } | ||
| - | ||
| - private YamlParser getYamlParser() { | ||
| - if( this.yamlParser == null ) { | ||
| - setYamlParser( new YamlParser() ); | ||
| - } | ||
| - | ||
| - return this.yamlParser; | ||
| - } | ||
| - | ||
| - private void setYamlParser( final YamlParser yamlParser ) { | ||
| - this.yamlParser = yamlParser; | ||
| - } | ||
| +import javafx.beans.value.ObservableBooleanValue; | ||
| +import javafx.collections.ListChangeListener.Change; | ||
| +import javafx.collections.ObservableList; | ||
| +import javafx.event.Event; | ||
| +import javafx.scene.Node; | ||
| +import javafx.scene.Scene; | ||
| +import javafx.scene.control.Alert; | ||
| +import javafx.scene.control.Alert.AlertType; | ||
| +import javafx.scene.control.Menu; | ||
| +import javafx.scene.control.MenuBar; | ||
| +import javafx.scene.control.SplitPane; | ||
| +import javafx.scene.control.Tab; | ||
| +import javafx.scene.control.ToolBar; | ||
| +import javafx.scene.control.TreeView; | ||
| +import javafx.scene.image.Image; | ||
| +import javafx.scene.image.ImageView; | ||
| +import static javafx.scene.input.KeyCode.ESCAPE; | ||
| +import javafx.scene.input.KeyEvent; | ||
| +import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED; | ||
| +import static javafx.scene.input.KeyEvent.KEY_PRESSED; | ||
| +import javafx.scene.layout.BorderPane; | ||
| +import javafx.scene.layout.VBox; | ||
| +import javafx.stage.Window; | ||
| +import javafx.stage.WindowEvent; | ||
| + | ||
| +/** | ||
| + * Main window containing a tab pane in the center for file editors. | ||
| + * | ||
| + * @author Karl Tauber and White Magic Software, Ltd. | ||
| + */ | ||
| +public class MainWindow { | ||
| + | ||
| + private final Options options = Services.load( Options.class ); | ||
| + | ||
| + private Scene scene; | ||
| + | ||
| + private TreeView<String> treeView; | ||
| + private FileEditorTabPane fileEditorPane; | ||
| + private DefinitionPane definitionPane; | ||
| + | ||
| + private VariableNameInjector variableNameInjector; | ||
| + | ||
| + private YamlTreeAdapter yamlTreeAdapter; | ||
| + private YamlParser yamlParser; | ||
| + | ||
| + private MenuBar menuBar; | ||
| + | ||
| + public MainWindow() { | ||
| + initLayout(); | ||
| + initVariableNameInjector(); | ||
| + } | ||
| + | ||
| + private void initLayout() { | ||
| + final SplitPane splitPane = new SplitPane( | ||
| + getDefinitionPane().getNode(), | ||
| + getFileEditorPane().getNode() ); | ||
| + | ||
| + splitPane.setDividerPositions( | ||
| + getFloat( K_PANE_SPLIT_DEFINITION, .05f ), | ||
| + getFloat( K_PANE_SPLIT_EDITOR, .95f ) ); | ||
| + | ||
| + // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restore-splitpane.html | ||
| + final BorderPane borderPane = new BorderPane(); | ||
| + borderPane.setPrefSize( 1024, 800 ); | ||
| + borderPane.setTop( createMenuBar() ); | ||
| + borderPane.setCenter( splitPane ); | ||
| + | ||
| + setScene( new Scene( borderPane ) ); | ||
| + getScene().getStylesheets().add( Constants.STYLESHEET_PREVIEW ); | ||
| + getScene().windowProperty().addListener( | ||
| + (observable, oldWindow, newWindow) -> { | ||
| + newWindow.setOnCloseRequest( e -> { | ||
| + if( !getFileEditorPane().closeAllEditors() ) { | ||
| + e.consume(); | ||
| + } | ||
| + } ); | ||
| + | ||
| + // Workaround JavaFX bug: deselect menubar if window loses focus. | ||
| + newWindow.focusedProperty().addListener( | ||
| + (obs, oldFocused, newFocused) -> { | ||
| + if( !newFocused ) { | ||
| + // Send an ESC key event to the menubar | ||
| + this.menuBar.fireEvent( | ||
| + new KeyEvent( | ||
| + KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE, | ||
| + false, false, false, false ) ); | ||
| + } | ||
| + } ); | ||
| + } ); | ||
| + } | ||
| + | ||
| + private void initVariableNameInjector() { | ||
| + setVariableNameInjector( new VariableNameInjector( | ||
| + getFileEditorPane(), | ||
| + getDefinitionPane() ) | ||
| + ); | ||
| + } | ||
| + | ||
| + private Window getWindow() { | ||
| + return getScene().getWindow(); | ||
| + } | ||
| + | ||
| + public Scene getScene() { | ||
| + return this.scene; | ||
| + } | ||
| + | ||
| + private void setScene( Scene scene ) { | ||
| + this.scene = scene; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Creates a boolean property that is bound to another boolean value of the | ||
| + * active editor. | ||
| + */ | ||
| + private BooleanProperty createActiveBooleanProperty( | ||
| + final Function<FileEditorTab, ObservableBooleanValue> func ) { | ||
| + | ||
| + final BooleanProperty b = new SimpleBooleanProperty(); | ||
| + final FileEditorTab fileEditor = getActiveFileEditor(); | ||
| + | ||
| + if( fileEditor != null ) { | ||
| + b.bind( func.apply( fileEditor ) ); | ||
| + } | ||
| + | ||
| + getFileEditorPane().activeFileEditorProperty().addListener( | ||
| + (observable, oldFileEditor, newFileEditor) -> { | ||
| + b.unbind(); | ||
| + | ||
| + if( newFileEditor != null ) { | ||
| + b.bind( func.apply( newFileEditor ) ); | ||
| + } else { | ||
| + b.set( false ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + return b; | ||
| + } | ||
| + | ||
| + //---- File actions ------------------------------------------------------- | ||
| + private void fileNew() { | ||
| + getFileEditorPane().newEditor(); | ||
| + } | ||
| + | ||
| + private void fileOpen() { | ||
| + getFileEditorPane().openFileDialog(); | ||
| + } | ||
| + | ||
| + private void fileClose() { | ||
| + getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | ||
| + } | ||
| + | ||
| + private void fileCloseAll() { | ||
| + getFileEditorPane().closeAllEditors(); | ||
| + } | ||
| + | ||
| + private void fileSave() { | ||
| + getFileEditorPane().saveEditor( getActiveFileEditor() ); | ||
| + } | ||
| + | ||
| + private void fileSaveAll() { | ||
| + getFileEditorPane().saveAllEditors(); | ||
| + } | ||
| + | ||
| + private void fileExit() { | ||
| + final Window window = getWindow(); | ||
| + Event.fireEvent( window, | ||
| + new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) ); | ||
| + } | ||
| + | ||
| + //---- Tools actions ------------------------------------------------------ | ||
| + private void toolsOptions() { | ||
| + new OptionsDialog( getWindow() ).showAndWait(); | ||
| + } | ||
| + | ||
| + //---- Help actions ------------------------------------------------------- | ||
| + private void helpAbout() { | ||
| + Alert alert = new Alert( AlertType.INFORMATION ); | ||
| + alert.setTitle( Messages.get( "Dialog.about.title" ) ); | ||
| + alert.setHeaderText( Messages.get( "Dialog.about.header" ) ); | ||
| + alert.setContentText( Messages.get( "Dialog.about.content" ) ); | ||
| + alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) ); | ||
| + alert.initOwner( getWindow() ); | ||
| + | ||
| + alert.showAndWait(); | ||
| + } | ||
| + | ||
| + private FileEditorTabPane getFileEditorPane() { | ||
| + if( this.fileEditorPane == null ) { | ||
| + this.fileEditorPane = createFileEditorPane(); | ||
| + } | ||
| + | ||
| + return this.fileEditorPane; | ||
| + } | ||
| + | ||
| + private FileEditorTabPane createFileEditorPane() { | ||
| + // Create an editor pane to hold file editor tabs. | ||
| + final FileEditorTabPane editorPane = new FileEditorTabPane(); | ||
| + | ||
| + // Make sure the text processor kicks off when new files are opened. | ||
| + final ObservableList<Tab> tabs = editorPane.getTabs(); | ||
| + | ||
| + tabs.addListener( (Change<? extends Tab> change) -> { | ||
| + while( change.next() ) { | ||
| + if( change.wasAdded() ) { | ||
| + for( final Tab tab : change.getAddedSubList() ) { | ||
| + final FileEditorTab feTab = (FileEditorTab)tab; | ||
| + | ||
| + // Load file and create UI when the tab becomes visible the first time. | ||
| + final HTMLPreviewPane previewPane = feTab.getPreviewPane(); | ||
| + | ||
| + // TODO: Change this to use a factory based on the filename extension. | ||
| + // See: https://github.com/DaveJarvis/scrivenvar/issues/17 | ||
| + // See: https://github.com/DaveJarvis/scrivenvar/issues/18 | ||
| + final Processor<String> hpp = new HTMLPreviewProcessor( previewPane ); | ||
| + final Processor<String> mp = new MarkdownProcessor( hpp ); | ||
| + final Processor<String> vnp = new VariableProcessor( mp, getResolvedMap() ); | ||
| + final TextChangeProcessor tp = new TextChangeProcessor( vnp ); | ||
| + | ||
| + feTab.getEditorPane().addChangeListener( tp ); | ||
| + System.out.println( "TAB WAS ADDED" ); | ||
| + } | ||
| + } else if( change.wasRemoved() ) { | ||
| + System.out.println( "TAB WAS REMOVED" ); | ||
| + } | ||
| + } | ||
| + } ); | ||
| + | ||
| + /* | ||
| + editorPane.getActiveFileEditor().getEditorPane().getEditor().focusedProperty().addListener( | ||
| + (ObservableValue<? extends Boolean> b, Boolean oldB, Boolean newB) -> { | ||
| + | ||
| + if( newB ) { | ||
| + System.out.println( "Textfield on focus" ); | ||
| + } else { | ||
| + System.out.println( "Textfield out focus" ); | ||
| + } | ||
| + } | ||
| + ); | ||
| + */ | ||
| +// editorPane.getActiveFileEditor().addChangeListener( tp ); | ||
| + return editorPane; | ||
| + } | ||
| + | ||
| + private MarkdownEditorPane getActiveEditor() { | ||
| + return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane()); | ||
| + } | ||
| + | ||
| + private FileEditorTab getActiveFileEditor() { | ||
| + return getFileEditorPane().getActiveFileEditor(); | ||
| + } | ||
| + | ||
| + protected DefinitionPane createDefinitionPane() { | ||
| + return new DefinitionPane( getTreeView() ); | ||
| + } | ||
| + | ||
| + private DefinitionPane getDefinitionPane() { | ||
| + if( this.definitionPane == null ) { | ||
| + this.definitionPane = createDefinitionPane(); | ||
| + } | ||
| + | ||
| + return this.definitionPane; | ||
| + } | ||
| + | ||
| + public MenuBar getMenuBar() { | ||
| + return menuBar; | ||
| + } | ||
| + | ||
| + public void setMenuBar( MenuBar menuBar ) { | ||
| + this.menuBar = menuBar; | ||
| + } | ||
| + | ||
| + public VariableNameInjector getVariableNameInjector() { | ||
| + return this.variableNameInjector; | ||
| + } | ||
| + | ||
| + public void setVariableNameInjector( VariableNameInjector variableNameInjector ) { | ||
| + this.variableNameInjector = variableNameInjector; | ||
| + } | ||
| + | ||
| + private float getFloat( final String key, final float defaultValue ) { | ||
| + return getPreferences().getFloat( key, defaultValue ); | ||
| + } | ||
| + | ||
| + private Preferences getPreferences() { | ||
| + return getOptions().getState(); | ||
| + } | ||
| + | ||
| + private Options getOptions() { | ||
| + return this.options; | ||
| + } | ||
| + | ||
| + private synchronized TreeView<String> getTreeView() throws RuntimeException { | ||
| + if( this.treeView == null ) { | ||
| + try { | ||
| + this.treeView = createTreeView(); | ||
| + } catch( IOException ex ) { | ||
| + | ||
| + // TODO: Pop an error message. | ||
| + throw new RuntimeException( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + return this.treeView; | ||
| + } | ||
| + | ||
| + private InputStream asStream( final String resource ) { | ||
| + return getClass().getResourceAsStream( resource ); | ||
| + } | ||
| + | ||
| + private TreeView<String> createTreeView() throws IOException { | ||
| + // TODO: Associate variable file with path to current file. | ||
| + return getYamlTreeAdapter().adapt( | ||
| + asStream( "/com/scrivenvar/variables.yaml" ), | ||
| + get( "Pane.defintion.node.root.title" ) | ||
| + ); | ||
| + } | ||
| + | ||
| + private Map<String, String> getResolvedMap() { | ||
| + return getYamlParser().createResolvedMap(); | ||
| + } | ||
| + | ||
| + private YamlTreeAdapter getYamlTreeAdapter() { | ||
| + if( this.yamlTreeAdapter == null ) { | ||
| + setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) ); | ||
| + } | ||
| + | ||
| + return this.yamlTreeAdapter; | ||
| + } | ||
| + | ||
| + private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) { | ||
| + this.yamlTreeAdapter = yamlTreeAdapter; | ||
| + } | ||
| + | ||
| + private YamlParser getYamlParser() { | ||
| + if( this.yamlParser == null ) { | ||
| + setYamlParser( new YamlParser() ); | ||
| + } | ||
| + | ||
| + return this.yamlParser; | ||
| + } | ||
| + | ||
| + private void setYamlParser( final YamlParser yamlParser ) { | ||
| + this.yamlParser = yamlParser; | ||
| + } | ||
| + | ||
| + private Node createMenuBar() { | ||
| + final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull(); | ||
| + | ||
| + // File actions | ||
| + Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() ); | ||
| + Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() ); | ||
| + Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull ); | ||
| + Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull ); | ||
| + Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(), | ||
| + createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() ); | ||
| + Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(), | ||
| + Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | ||
| + Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() ); | ||
| + | ||
| + // Edit actions | ||
| + Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO, | ||
| + e -> getActiveEditor().undo(), | ||
| + createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() ); | ||
| + Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT, | ||
| + e -> getActiveEditor().redo(), | ||
| + createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() ); | ||
| + | ||
| + // Insert actions | ||
| + Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD, | ||
| + e -> getActiveEditor().surroundSelection( "**", "**" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | ||
| + e -> getActiveEditor().surroundSelection( "*", "*" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | ||
| + e -> getActiveEditor().surroundSelection( "~~", "~~" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac | ||
| + e -> getActiveEditor().surroundSelection( "\n\n> ", "" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE, | ||
| + e -> getActiveEditor().surroundSelection( "`", "`" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT, | ||
| + e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ), | ||
| + activeFileEditorIsNull ); | ||
| + | ||
| + Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK, | ||
| + e -> getActiveEditor().insertLink(), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT, | ||
| + e -> getActiveEditor().insertImage(), | ||
| + activeFileEditorIsNull ); | ||
| + | ||
| + final Action[] headers = new Action[ 6 ]; | ||
| + | ||
| + // Insert header actions (H1 ... H6) | ||
| + for( int i = 1; i <= 6; i++ ) { | ||
| + final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | ||
| + final String markup = String.format( "\n\n%s ", hashes ); | ||
| + final String text = Messages.get( "Main.menu.insert.header_" + i ); | ||
| + final String accelerator = "Shortcut+" + i; | ||
| + final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" ); | ||
| + | ||
| + headers[ i - 1 ] = new Action( text, accelerator, HEADER, | ||
| + e -> getActiveEditor().surroundSelection( markup, "", prompt ), | ||
| + activeFileEditorIsNull ); | ||
| + } | ||
| + | ||
| + Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | ||
| + e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | ||
| + e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | ||
| + activeFileEditorIsNull ); | ||
| + Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | ||
| + e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | ||
| + activeFileEditorIsNull ); | ||
| + | ||
| + // Tools actions | ||
| + Action toolsOptionsAction = new Action( Messages.get( "Main.menu.tools.options" ), "Shortcut+,", null, e -> toolsOptions() ); | ||
| + | ||
| + // Help actions | ||
| + Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | ||
| + | ||
| + //---- MenuBar ---- | ||
| + Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ), | ||
| + fileNewAction, | ||
| + fileOpenAction, | ||
| + null, | ||
| + fileCloseAction, | ||
| + fileCloseAllAction, | ||
| + null, | ||
| + fileSaveAction, | ||
| + fileSaveAllAction, | ||
| + null, | ||
| + fileExitAction ); | ||
| + | ||
| + Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ), | ||
| + editUndoAction, | ||
| + editRedoAction ); | ||
| + | ||
| + Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ), | ||
| + insertBoldAction, | ||
| + insertItalicAction, | ||
| + insertStrikethroughAction, | ||
| + insertBlockquoteAction, | ||
| + insertCodeAction, | ||
| + insertFencedCodeBlockAction, | ||
| + null, | ||
| + insertLinkAction, | ||
| + insertImageAction, | ||
| + null, | ||
| + headers[ 0 ], | ||
| + headers[ 1 ], | ||
| + headers[ 2 ], | ||
| + headers[ 3 ], | ||
| + headers[ 4 ], | ||
| + headers[ 5 ], | ||
| + null, | ||
| + insertUnorderedListAction, | ||
| + insertOrderedListAction, | ||
| + insertHorizontalRuleAction ); | ||
| + | ||
| + Menu toolsMenu = ActionUtils.createMenu( Messages.get( "Main.menu.tools" ), | ||
| + toolsOptionsAction ); | ||
| + | ||
| + Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ), | ||
| + helpAboutAction ); | ||
| + | ||
| + menuBar = new MenuBar( fileMenu, editMenu, insertMenu, toolsMenu, helpMenu ); | ||
| + | ||
| + //---- ToolBar ---- | ||
| + ToolBar toolBar = ActionUtils.createToolBar( | ||
| + fileNewAction, | ||
| + fileOpenAction, | ||
| + fileSaveAction, | ||
| + null, | ||
| + editUndoAction, | ||
| + editRedoAction, | ||
| + null, | ||
| + insertBoldAction, | ||
| + insertItalicAction, | ||
| + insertBlockquoteAction, | ||
| + insertCodeAction, | ||
| + insertFencedCodeBlockAction, | ||
| + null, | ||
| + insertLinkAction, | ||
| + insertImageAction, | ||
| + null, | ||
| + headers[ 0 ], | ||
| + null, | ||
| + insertUnorderedListAction, | ||
| + insertOrderedListAction ); | ||
| + | ||
| + return new VBox( menuBar, toolBar ); | ||
| + } | ||
| + | ||
| } | ||