Dave Jarvis' Repositories

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

Moved from composition to inheritance. Added listener for changes to tabs. Wired preview update to tab changes.

Authordjarvis <email>
Date2016-12-05 08:39:52 GMT-0800
Commitc722d7e9a69b0af24449975d0f480129f1af3937
Parentfd95319
Delta1851 lines added, 1380 lines removed, 471-line increase
src/main/java/com/scrivenvar/service/Service.java
/**
* All services inherit from this one.
- *
+ *
* @author White Magic Software, Ltd.
*/
public interface Service {
-
}
src/main/java/com/scrivenvar/editor/MarkdownEditorPane.java
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";
}
src/main/java/com/scrivenvar/editor/VariableNameInjector.java
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;
}
src/main/java/com/scrivenvar/FileEditorTabPane.java
+/*
+ * 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();
+ }
+
+}
src/main/java/com/scrivenvar/MainWindow.java
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 );
+ }
+
}