Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
1
version = '1.0.11'
1
version = '1.0.12'
22
33
apply plugin: 'java'
M src/main/java/com/scrivenvar/AbstractFileFactory.java
2828
package com.scrivenvar;
2929
30
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
3031
import com.scrivenvar.predicates.files.FileTypePredicate;
3132
import com.scrivenvar.service.Settings;
...
4546
4647
  /**
47
   * Creates a definition source that can read and write files that match the
48
   * given file type (from the path).
48
   * Determines the file type from the path extension. This should only be
49
   * called when it is known that the file type won't be a definition file
50
   * (e.g., YAML or other definition source), but rather an editable file
51
   * (e.g., Markdown, XML, etc.).
52
   *
53
   * @param path The path with a file name extension.
54
   *
55
   * @return The FileType for the given path.
56
   */
57
  public FileType lookup( final Path path ) {
58
    return lookup( path, GLOB_PREFIX_FILE );
59
  }
60
61
  /**
62
   * Creates a file type that corresponds to the given path.
4963
   *
5064
   * @param path Reference to a variable definition file.
M src/main/java/com/scrivenvar/decorators/RVariableDecorator.java
3535
public class RVariableDecorator implements VariableDecorator {
3636
  public static final String PREFIX = "`r#";
37
  public static final String SUFFIX = "`";
37
  public static final char SUFFIX = '`';
3838
3939
  /**
...
4747
  @Override
4848
  public String decorate( final String variableName ) {
49
    return PREFIX + "x(" + variableName + ")" + SUFFIX ;
49
    // 12 = PREFIX + x(...) + SUFFIX + 1 for good measure
50
    final StringBuilder sb = new StringBuilder( variableName.length() + 12 );
51
    
52
    sb.append( PREFIX );
53
    sb.append( "x( v$" );
54
    sb.append( variableName.replace( '.', '$' ) );
55
    sb.append( " )" );
56
    sb.append( SUFFIX );
57
    
58
    return sb.toString();
5059
  }
5160
}
A src/main/java/com/scrivenvar/editors/VariableNameDecoratorFactory.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.editors;
29
30
import com.scrivenvar.AbstractFileFactory;
31
import com.scrivenvar.decorators.RVariableDecorator;
32
import com.scrivenvar.decorators.VariableDecorator;
33
import com.scrivenvar.decorators.YamlVariableDecorator;
34
import java.nio.file.Path;
35
36
/**
37
 * Responsible for creating a variable name decorator suited to a particular
38
 * file type.
39
 *
40
 * @author White Magic Software, Ltd.
41
 */
42
public class VariableNameDecoratorFactory extends AbstractFileFactory {
43
44
  private VariableNameDecoratorFactory() {
45
  }
46
47
  public static VariableDecorator newInstance( final Path path ) {
48
    final VariableNameDecoratorFactory f = new VariableNameDecoratorFactory();
49
    final VariableDecorator result;
50
51
    switch( f.lookup( path ) ) {
52
      case RMARKDOWN:
53
      case RXML:
54
        result = new RVariableDecorator();
55
        break;
56
57
      default:
58
        result = new YamlVariableDecorator();
59
        break;
60
    }
61
62
    return result;
63
  }
64
}
165
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
3131
import com.scrivenvar.Services;
3232
import com.scrivenvar.decorators.VariableDecorator;
33
import com.scrivenvar.decorators.YamlVariableDecorator;
34
import com.scrivenvar.definition.DefinitionPane;
35
import com.scrivenvar.definition.VariableTreeItem;
36
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
37
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR;
38
import com.scrivenvar.service.Settings;
39
import static com.scrivenvar.util.Lists.getFirst;
40
import static com.scrivenvar.util.Lists.getLast;
41
import static java.lang.Character.isSpaceChar;
42
import static java.lang.Character.isWhitespace;
43
import static java.lang.Math.min;
44
import java.util.function.Consumer;
45
import javafx.collections.ObservableList;
46
import javafx.event.Event;
47
import javafx.scene.control.IndexRange;
48
import javafx.scene.control.TreeItem;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.input.KeyCode;
51
import static javafx.scene.input.KeyCode.AT;
52
import static javafx.scene.input.KeyCode.DIGIT2;
53
import static javafx.scene.input.KeyCode.ENTER;
54
import static javafx.scene.input.KeyCode.MINUS;
55
import static javafx.scene.input.KeyCode.SPACE;
56
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
57
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
58
import javafx.scene.input.KeyEvent;
59
import org.fxmisc.richtext.StyledTextArea;
60
import org.fxmisc.wellbehaved.event.EventPattern;
61
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
62
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
63
import org.fxmisc.wellbehaved.event.InputMap;
64
import static org.fxmisc.wellbehaved.event.InputMap.consume;
65
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
66
67
/**
68
 * Provides the logic for injecting variable names within the editor.
69
 *
70
 * @author White Magic Software, Ltd.
71
 */
72
public class VariableNameInjector {
73
74
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
75
76
  private static final int NO_DIFFERENCE = -1;
77
78
  private final Settings settings = Services.load( Settings.class );
79
80
  /**
81
   * Used to capture keyboard events once the user presses @.
82
   */
83
  private InputMap<InputEvent> keyboardMap;
84
85
  private FileEditorTab tab;
86
  private DefinitionPane definitionPane;
87
88
  /**
89
   * Position of the variable in the text when in variable mode (0 by default).
90
   */
91
  private int initialCaretPosition;
92
93
  private VariableNameInjector() {
94
  }
95
96
  public static void listen( final FileEditorTab tab, final DefinitionPane pane ) {
97
    VariableNameInjector vni = new VariableNameInjector();
98
99
    vni.setFileEditorTab( tab );
100
    vni.setDefinitionPane( pane );
101
102
    vni.initKeyboardEventListeners();
103
  }
104
105
  /**
106
   * Traps keys for performing various short-cut tasks, such as @-mode variable
107
   * insertion and control+space for variable autocomplete.
108
   *
109
   * @ key is pressed, a new keyboard map is inserted in place of the current
110
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
111
   *
112
   * @see createKeyboardMap()
113
   */
114
  private void initKeyboardEventListeners() {
115
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
116
117
    // @ key in Linux?
118
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
119
    // @ key in Windows.
120
    addEventListener( keyPressed( AT ), this::vMode );
121
  }
122
123
  /**
124
   * The @ symbol is a short-cut to inserting a YAML variable reference.
125
   *
126
   * @param e Superfluous information about the key that was pressed.
127
   */
128
  private void vMode( KeyEvent e ) {
129
    setInitialCaretPosition();
130
    vModeStart();
131
    vModeAutocomplete();
132
  }
133
134
  /**
135
   * Receives key presses until the user completes the variable selection. This
136
   * allows the arrow keys to be used for selecting variables.
137
   *
138
   * @param e The key that was pressed.
139
   */
140
  private void vModeKeyPressed( KeyEvent e ) {
141
    final KeyCode keyCode = e.getCode();
142
143
    switch( keyCode ) {
144
      case BACK_SPACE:
145
        // Don't decorate the variable upon exiting vMode.
146
        vModeBackspace();
147
        break;
148
149
      case ESCAPE:
150
        // Don't decorate the variable upon exiting vMode.
151
        vModeStop();
152
        break;
153
154
      case ENTER:
155
      case PERIOD:
156
      case RIGHT:
157
      case END:
158
        // Stop at a leaf node, ENTER means accept.
159
        if( vModeConditionalComplete() && keyCode == ENTER ) {
160
          vModeStop();
161
162
          // Decorate the variable upon exiting vMode.
163
          decorateVariable();
164
        }
165
        break;
166
167
      case UP:
168
        cyclePathPrev();
169
        break;
170
171
      case DOWN:
172
        cyclePathNext();
173
        break;
174
175
      default:
176
        vModeFilterKeyPressed( e );
177
        break;
178
    }
179
180
    e.consume();
181
  }
182
183
  private void vModeBackspace() {
184
    deleteSelection();
185
186
    // Break out of variable mode by back spacing to the original position.
187
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
188
      vModeAutocomplete();
189
    }
190
    else {
191
      vModeStop();
192
    }
193
  }
194
195
  /**
196
   * Updates the text with the path selected (or typed) by the user.
197
   */
198
  private void vModeAutocomplete() {
199
    final TreeItem<String> node = getCurrentNode();
200
201
    if( node != null && !node.isLeaf() ) {
202
      final String word = getLastPathWord();
203
      final String label = node.getValue();
204
      final int delta = difference( label, word );
205
      final String remainder = delta == NO_DIFFERENCE
206
        ? label
207
        : label.substring( delta );
208
209
      final StyledTextArea textArea = getEditor();
210
      final int posBegan = getCurrentCaretPosition();
211
      final int posEnded = posBegan + remainder.length();
212
213
      textArea.replaceSelection( remainder );
214
215
      if( posEnded - posBegan > 0 ) {
216
        textArea.selectRange( posEnded, posBegan );
217
      }
218
219
      expand( node );
220
    }
221
  }
222
223
  /**
224
   * Only variable name keys can pass through the filter. This is called when
225
   * the user presses a key.
226
   *
227
   * @param e The key that was pressed.
228
   */
229
  private void vModeFilterKeyPressed( final KeyEvent e ) {
230
    if( isVariableNameKey( e ) ) {
231
      typed( e.getText() );
232
    }
233
  }
234
235
  /**
236
   * Performs an autocomplete depending on whether the user has finished typing
237
   * in a word. If there is a selected range, then this will complete the most
238
   * recent word and jump to the next child.
239
   *
240
   * @return true The auto-completed node was a terminal node.
241
   */
242
  private boolean vModeConditionalComplete() {
243
    acceptPath();
244
245
    final TreeItem<String> node = getCurrentNode();
246
    final boolean terminal = isTerminal( node );
247
248
    if( !terminal ) {
249
      typed( SEPARATOR );
250
    }
251
252
    return terminal;
253
  }
254
255
  /**
256
   * Pressing control+space will find a node that matches the current word and
257
   * substitute the YAML variable reference. This is called when the user is not
258
   * editing in vMode.
259
   *
260
   * @param e Ignored -- it can only be Ctrl+Space.
261
   */
262
  private void autocomplete( final KeyEvent e ) {
263
    final String paragraph = getCaretParagraph();
264
    final int[] boundaries = getWordBoundaries( paragraph );
265
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
266
267
    final VariableTreeItem<String> leaf = findLeaf( word );
268
269
    if( leaf != null ) {
270
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
271
      decorateVariable();
272
      expand( leaf );
273
    }
274
  }
275
276
  /**
277
   * Called when autocomplete finishes on a valid leaf or when the user presses
278
   * Enter to finish manual autocomplete.
279
   */
280
  private void decorateVariable() {
281
    // A little bit of duplication...
282
    final String paragraph = getCaretParagraph();
283
    final int[] boundaries = getWordBoundaries( paragraph );
284
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
285
286
    final String newVariable = getVariableDecorator().decorate( old );
287
288
    final int posEnded = getCurrentCaretPosition();
289
    final int posBegan = posEnded - old.length();
290
291
    getEditor().replaceText( posBegan, posEnded, newVariable );
292
  }
293
294
  /**
295
   * Updates the text at the given position within the current paragraph.
296
   *
297
   * @param posBegan The starting index in the paragraph text to replace.
298
   * @param posEnded The ending index in the paragraph text to replace.
299
   * @param text Overwrite the paragraph substring with this text.
300
   */
301
  private void replaceText(
302
    final int posBegan, final int posEnded, final String text ) {
303
    final int p = getCurrentParagraph();
304
305
    getEditor().replaceText( p, posBegan, p, posEnded, text );
306
  }
307
308
  /**
309
   * Returns the caret's current paragraph position.
310
   *
311
   * @return A number greater than or equal to 0.
312
   */
313
  private int getCurrentParagraph() {
314
    return getEditor().getCurrentParagraph();
315
  }
316
317
  /**
318
   * Returns current word boundary indexes into the current paragraph, including
319
   * punctuation.
320
   *
321
   * @param p The paragraph wherein to hunt word boundaries.
322
   * @param offset The offset into the paragraph to begin scanning left and
323
   * right.
324
   *
325
   * @return The starting and ending index of the word closest to the caret.
326
   */
327
  private int[] getWordBoundaries( final String p, final int offset ) {
328
    // Remove dashes, but retain hyphens. Retain same number of characters
329
    // to preserve relative indexes.
330
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
331
332
    return getWordAt( paragraph, offset );
333
  }
334
335
  /**
336
   * Helper method to get the word boundaries for the current paragraph.
337
   *
338
   * @param paragraph
339
   *
340
   * @return
341
   */
342
  private int[] getWordBoundaries( final String paragraph ) {
343
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
344
  }
345
346
  /**
347
   * Given an arbitrary offset into a string, this returns the word at that
348
   * index. The inputs and outputs include:
349
   *
350
   * <ul>
351
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
352
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
353
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
354
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
355
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
356
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
357
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
358
   * </ul>
359
   *
360
   * @param p The string to scan for a word.
361
   * @param offset The offset within s to begin searching for the nearest word
362
   * boundary, must not be out of bounds of s.
363
   *
364
   * @return The word in s at the offset.
365
   *
366
   * @see getWordBegan( String, int )
367
   * @see getWordEnded( String, int )
368
   */
369
  private int[] getWordAt( final String p, final int offset ) {
370
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
371
  }
372
373
  /**
374
   * Returns the index into s where a word begins.
375
   *
376
   * @param s Never null.
377
   * @param offset Index into s to begin searching backwards for a word
378
   * boundary.
379
   *
380
   * @return The index where a word begins.
381
   */
382
  private int getWordBegan( final String s, int offset ) {
383
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
384
      offset--;
385
    }
386
387
    return offset;
388
  }
389
390
  /**
391
   * Returns the index into s where a word ends.
392
   *
393
   * @param s Never null.
394
   * @param offset Index into s to begin searching forwards for a word boundary.
395
   *
396
   * @return The index where a word ends.
397
   */
398
  private int getWordEnded( final String s, int offset ) {
399
    final int length = s.length();
400
401
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
402
      offset++;
403
    }
404
405
    return offset;
406
  }
407
408
  /**
409
   * Returns true if the given character can be reasonably expected to be part
410
   * of a word, including punctuation marks.
411
   *
412
   * @param c The character to compare.
413
   *
414
   * @return false The character is a space character.
415
   */
416
  private boolean isBoundary( final char c ) {
417
    return !isSpaceChar( c );
418
  }
419
420
  /**
421
   * Returns the text for the paragraph that contains the caret.
422
   *
423
   * @return A non-null string, possibly empty.
424
   */
425
  private String getCaretParagraph() {
426
    return getEditor().getText( getCurrentParagraph() );
427
  }
428
429
  /**
430
   * Returns true if the node has children that can be selected (i.e., any
431
   * non-leaves).
432
   *
433
   * @param <T> The type that the TreeItem contains.
434
   * @param node The node to test for terminality.
435
   *
436
   * @return true The node has one branch and its a leaf.
437
   */
438
  private <T> boolean isTerminal( final TreeItem<T> node ) {
439
    final ObservableList<TreeItem<T>> branches = node.getChildren();
440
441
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
442
  }
443
444
  /**
445
   * Inserts text that the user typed at the current caret position, then
446
   * performs an autocomplete for the variable name.
447
   *
448
   * @param text The text to insert, never null.
449
   */
450
  private void typed( final String text ) {
451
    getEditor().replaceSelection( text );
452
    vModeAutocomplete();
453
  }
454
455
  /**
456
   * Called when the user presses either End or Enter key.
457
   */
458
  private void acceptPath() {
459
    final IndexRange range = getSelectionRange();
460
461
    if( range != null ) {
462
      final int rangeEnd = range.getEnd();
463
      final StyledTextArea textArea = getEditor();
464
      textArea.deselect();
465
      textArea.moveTo( rangeEnd );
466
    }
467
  }
468
469
  /**
470
   * Replaces the entirety of the existing path (from the initial caret
471
   * position) with the given path.
472
   *
473
   * @param oldPath The path to replace.
474
   * @param newPath The replacement path.
475
   */
476
  private void replacePath( final String oldPath, final String newPath ) {
477
    final StyledTextArea textArea = getEditor();
478
    final int posBegan = getInitialCaretPosition();
479
    final int posEnded = posBegan + oldPath.length();
480
481
    textArea.deselect();
482
    textArea.replaceText( posBegan, posEnded, newPath );
483
  }
484
485
  /**
486
   * Called when the user presses the Backspace key.
487
   */
488
  private void deleteSelection() {
489
    final StyledTextArea textArea = getEditor();
490
    textArea.replaceSelection( "" );
491
    textArea.deletePreviousChar();
492
  }
493
494
  /**
495
   * Cycles the selected text through the nodes.
496
   *
497
   * @param direction true - next; false - previous
498
   */
499
  private void cycleSelection( final boolean direction ) {
500
    final TreeItem<String> node = getCurrentNode();
501
502
    // Find the sibling for the current selection and replace the current
503
    // selection with the sibling's value
504
    TreeItem< String> cycled = direction
505
      ? node.nextSibling()
506
      : node.previousSibling();
507
508
    // When cycling at the end (or beginning) of the list, jump to the first
509
    // (or last) sibling depending on the cycle direction.
510
    if( cycled == null ) {
511
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
512
    }
513
514
    final String path = getCurrentPath();
515
    final String cycledWord = cycled.getValue();
516
    final String word = getLastPathWord();
517
    final int index = path.indexOf( word );
518
    final String cycledPath = path.substring( 0, index ) + cycledWord;
519
520
    expand( cycled );
521
    replacePath( path, cycledPath );
522
  }
523
524
  /**
525
   * Cycles to the next sibling of the currently selected tree node.
526
   */
527
  private void cyclePathNext() {
528
    cycleSelection( true );
529
  }
530
531
  /**
532
   * Cycles to the previous sibling of the currently selected tree node.
533
   */
534
  private void cyclePathPrev() {
535
    cycleSelection( false );
536
  }
537
538
  /**
539
   * Returns the variable name (or as much as has been typed so far). Returns
540
   * all the characters from the initial caret column to the the first
541
   * whitespace character. This will return a path that contains zero or more
542
   * separators.
543
   *
544
   * @return A non-null string, possibly empty.
545
   */
546
  private String getCurrentPath() {
547
    final String s = extractTextChunk();
548
    final int length = s.length();
549
550
    int i = 0;
551
552
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
553
      i++;
554
    }
555
556
    return s.substring( 0, i );
557
  }
558
559
  private <T> ObservableList<TreeItem<T>> getSiblings(
560
    final TreeItem<T> item ) {
561
    final TreeItem<T> parent = item.getParent();
562
    return parent == null ? item.getChildren() : parent.getChildren();
563
  }
564
565
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
566
    return getFirst( getSiblings( item ), item );
567
  }
568
569
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
570
    return getLast( getSiblings( item ), item );
571
  }
572
573
  /**
574
   * Returns the caret position as an offset into the text.
575
   *
576
   * @return A value from 0 to the length of the text (minus one).
577
   */
578
  private int getCurrentCaretPosition() {
579
    return getEditor().getCaretPosition();
580
  }
581
582
  /**
583
   * Returns the caret position within the current paragraph.
584
   *
585
   * @return A value from 0 to the length of the current paragraph.
586
   */
587
  private int getCurrentCaretColumn() {
588
    return getEditor().getCaretColumn();
589
  }
590
591
  /**
592
   * Returns the last word from the path.
593
   *
594
   * @return The last token.
595
   */
596
  private String getLastPathWord() {
597
    String path = getCurrentPath();
598
599
    int i = path.indexOf( SEPARATOR_CHAR );
600
601
    while( i > 0 ) {
602
      path = path.substring( i + 1 );
603
      i = path.indexOf( SEPARATOR_CHAR );
604
    }
605
606
    return path;
607
  }
608
609
  /**
610
   * Returns text from the initial caret position until some arbitrarily long
611
   * number of characters. The number of characters extracted will be
612
   * getMaxVarLength, or fewer, depending on how many characters remain to be
613
   * extracted. The result from this method is trimmed to the first whitespace
614
   * character.
615
   *
616
   * @return A chunk of text that includes all the words representing a path,
617
   * and then some.
618
   */
619
  private String extractTextChunk() {
620
    final StyledTextArea textArea = getEditor();
621
    final int textBegan = getInitialCaretPosition();
622
    final int remaining = textArea.getLength() - textBegan;
623
    final int textEnded = min( remaining, getMaxVarLength() );
624
625
    try {
626
      return textArea.getText( textBegan, textEnded );
627
    }
628
    catch( final Exception e ) {
629
      return textArea.getText();
630
    }
631
  }
632
633
  /**
634
   * Returns the node for the current path.
635
   */
636
  private TreeItem<String> getCurrentNode() {
637
    return findNode( getCurrentPath() );
638
  }
639
640
  /**
641
   * Finds the node that most closely matches the given path.
642
   *
643
   * @param path The path that represents a node.
644
   *
645
   * @return The node for the path, or the root node if the path could not be
646
   * found, but never null.
647
   */
648
  private TreeItem<String> findNode( final String path ) {
649
    return getDefinitionPane().findNode( path );
650
  }
651
652
  /**
653
   * Finds the first leaf having a value that starts with the given text.
654
   *
655
   * @param text The text to find in the definition tree.
656
   *
657
   * @return The leaf that starts with the given text, or null if not found.
658
   */
659
  private VariableTreeItem<String> findLeaf( final String text ) {
660
    return getDefinitionPane().findLeaf( text );
661
  }
662
663
  /**
664
   * Used to ignore typed keys in favour of trapping pressed keys.
665
   *
666
   * @param e The key that was typed.
667
   */
668
  private void vModeKeyTyped( KeyEvent e ) {
669
    e.consume();
670
  }
671
672
  /**
673
   * Used to lazily initialize the keyboard map.
674
   *
675
   * @return Mappings for keyTyped and keyPressed.
676
   */
677
  protected InputMap<InputEvent> createKeyboardMap() {
678
    return sequence(
679
      consume( keyTyped(), this::vModeKeyTyped ),
680
      consume( keyPressed(), this::vModeKeyPressed )
681
    );
682
  }
683
684
  private InputMap<InputEvent> getKeyboardMap() {
685
    if( this.keyboardMap == null ) {
686
      this.keyboardMap = createKeyboardMap();
687
    }
688
689
    return this.keyboardMap;
690
  }
691
692
  /**
693
   * Collapses the tree then expands and selects the given node.
694
   *
695
   * @param node The node to expand.
696
   */
697
  private void expand( final TreeItem<String> node ) {
698
    final DefinitionPane pane = getDefinitionPane();
699
    pane.collapse();
700
    pane.expand( node );
701
    pane.select( node );
702
  }
703
704
  /**
705
   * Returns true iff the key code the user typed can be used as part of a YAML
706
   * variable name.
707
   *
708
   * @param keyEvent Keyboard key press event information.
709
   *
710
   * @return true The key is a value that can be inserted into the text.
711
   */
712
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
713
    final KeyCode kc = keyEvent.getCode();
714
715
    return (kc.isLetterKey()
716
      || kc.isDigitKey()
717
      || (keyEvent.isShiftDown() && kc == MINUS))
718
      && !keyEvent.isControlDown();
719
  }
720
721
  /**
722
   * Starts to capture user input events.
723
   */
724
  private void vModeStart() {
725
    addEventListener( getKeyboardMap() );
726
  }
727
728
  /**
729
   * Restores capturing of user input events to the previous event listener.
730
   * Also asks the processing chain to modify the variable text into a
731
   * machine-readable variable based on the format required by the file type.
732
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
733
   * file (.Rmd, .Rxml) will use `r#xVAR`.
734
   */
735
  private void vModeStop() {
736
    removeEventListener( getKeyboardMap() );
737
  }
738
739
  private VariableDecorator getVariableDecorator() {
740
    return new YamlVariableDecorator();
33
import com.scrivenvar.definition.DefinitionPane;
34
import com.scrivenvar.definition.VariableTreeItem;
35
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
36
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR;
37
import com.scrivenvar.service.Settings;
38
import static com.scrivenvar.util.Lists.getFirst;
39
import static com.scrivenvar.util.Lists.getLast;
40
import static java.lang.Character.isSpaceChar;
41
import static java.lang.Character.isWhitespace;
42
import static java.lang.Math.min;
43
import java.nio.file.Path;
44
import java.util.function.Consumer;
45
import javafx.collections.ObservableList;
46
import javafx.event.Event;
47
import javafx.scene.control.IndexRange;
48
import javafx.scene.control.TreeItem;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.input.KeyCode;
51
import static javafx.scene.input.KeyCode.AT;
52
import static javafx.scene.input.KeyCode.DIGIT2;
53
import static javafx.scene.input.KeyCode.ENTER;
54
import static javafx.scene.input.KeyCode.MINUS;
55
import static javafx.scene.input.KeyCode.SPACE;
56
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
57
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
58
import javafx.scene.input.KeyEvent;
59
import org.fxmisc.richtext.StyledTextArea;
60
import org.fxmisc.wellbehaved.event.EventPattern;
61
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
62
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
63
import org.fxmisc.wellbehaved.event.InputMap;
64
import static org.fxmisc.wellbehaved.event.InputMap.consume;
65
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
66
67
/**
68
 * Provides the logic for injecting variable names within the editor.
69
 *
70
 * @author White Magic Software, Ltd.
71
 */
72
public class VariableNameInjector {
73
74
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
75
76
  private static final int NO_DIFFERENCE = -1;
77
78
  private final Settings settings = Services.load( Settings.class );
79
80
  /**
81
   * Used to capture keyboard events once the user presses @.
82
   */
83
  private InputMap<InputEvent> keyboardMap;
84
85
  private FileEditorTab tab;
86
  private DefinitionPane definitionPane;
87
88
  /**
89
   * Position of the variable in the text when in variable mode (0 by default).
90
   */
91
  private int initialCaretPosition;
92
93
  private VariableNameInjector() {
94
  }
95
96
  public static void listen( final FileEditorTab tab, final DefinitionPane pane ) {
97
    VariableNameInjector vni = new VariableNameInjector();
98
99
    vni.setFileEditorTab( tab );
100
    vni.setDefinitionPane( pane );
101
102
    vni.initKeyboardEventListeners();
103
  }
104
105
  /**
106
   * Traps keys for performing various short-cut tasks, such as @-mode variable
107
   * insertion and control+space for variable autocomplete.
108
   *
109
   * @ key is pressed, a new keyboard map is inserted in place of the current
110
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
111
   *
112
   * @see createKeyboardMap()
113
   */
114
  private void initKeyboardEventListeners() {
115
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
116
117
    // @ key in Linux?
118
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
119
    // @ key in Windows.
120
    addEventListener( keyPressed( AT ), this::vMode );
121
  }
122
123
  /**
124
   * The @ symbol is a short-cut to inserting a YAML variable reference.
125
   *
126
   * @param e Superfluous information about the key that was pressed.
127
   */
128
  private void vMode( KeyEvent e ) {
129
    setInitialCaretPosition();
130
    vModeStart();
131
    vModeAutocomplete();
132
  }
133
134
  /**
135
   * Receives key presses until the user completes the variable selection. This
136
   * allows the arrow keys to be used for selecting variables.
137
   *
138
   * @param e The key that was pressed.
139
   */
140
  private void vModeKeyPressed( KeyEvent e ) {
141
    final KeyCode keyCode = e.getCode();
142
143
    switch( keyCode ) {
144
      case BACK_SPACE:
145
        // Don't decorate the variable upon exiting vMode.
146
        vModeBackspace();
147
        break;
148
149
      case ESCAPE:
150
        // Don't decorate the variable upon exiting vMode.
151
        vModeStop();
152
        break;
153
154
      case ENTER:
155
      case PERIOD:
156
      case RIGHT:
157
      case END:
158
        // Stop at a leaf node, ENTER means accept.
159
        if( vModeConditionalComplete() && keyCode == ENTER ) {
160
          vModeStop();
161
162
          // Decorate the variable upon exiting vMode.
163
          decorateVariable();
164
        }
165
        break;
166
167
      case UP:
168
        cyclePathPrev();
169
        break;
170
171
      case DOWN:
172
        cyclePathNext();
173
        break;
174
175
      default:
176
        vModeFilterKeyPressed( e );
177
        break;
178
    }
179
180
    e.consume();
181
  }
182
183
  private void vModeBackspace() {
184
    deleteSelection();
185
186
    // Break out of variable mode by back spacing to the original position.
187
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
188
      vModeAutocomplete();
189
    }
190
    else {
191
      vModeStop();
192
    }
193
  }
194
195
  /**
196
   * Updates the text with the path selected (or typed) by the user.
197
   */
198
  private void vModeAutocomplete() {
199
    final TreeItem<String> node = getCurrentNode();
200
201
    if( node != null && !node.isLeaf() ) {
202
      final String word = getLastPathWord();
203
      final String label = node.getValue();
204
      final int delta = difference( label, word );
205
      final String remainder = delta == NO_DIFFERENCE
206
        ? label
207
        : label.substring( delta );
208
209
      final StyledTextArea textArea = getEditor();
210
      final int posBegan = getCurrentCaretPosition();
211
      final int posEnded = posBegan + remainder.length();
212
213
      textArea.replaceSelection( remainder );
214
215
      if( posEnded - posBegan > 0 ) {
216
        textArea.selectRange( posEnded, posBegan );
217
      }
218
219
      expand( node );
220
    }
221
  }
222
223
  /**
224
   * Only variable name keys can pass through the filter. This is called when
225
   * the user presses a key.
226
   *
227
   * @param e The key that was pressed.
228
   */
229
  private void vModeFilterKeyPressed( final KeyEvent e ) {
230
    if( isVariableNameKey( e ) ) {
231
      typed( e.getText() );
232
    }
233
  }
234
235
  /**
236
   * Performs an autocomplete depending on whether the user has finished typing
237
   * in a word. If there is a selected range, then this will complete the most
238
   * recent word and jump to the next child.
239
   *
240
   * @return true The auto-completed node was a terminal node.
241
   */
242
  private boolean vModeConditionalComplete() {
243
    acceptPath();
244
245
    final TreeItem<String> node = getCurrentNode();
246
    final boolean terminal = isTerminal( node );
247
248
    if( !terminal ) {
249
      typed( SEPARATOR );
250
    }
251
252
    return terminal;
253
  }
254
255
  /**
256
   * Pressing control+space will find a node that matches the current word and
257
   * substitute the YAML variable reference. This is called when the user is not
258
   * editing in vMode.
259
   *
260
   * @param e Ignored -- it can only be Ctrl+Space.
261
   */
262
  private void autocomplete( final KeyEvent e ) {
263
    final String paragraph = getCaretParagraph();
264
    final int[] boundaries = getWordBoundaries( paragraph );
265
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
266
267
    final VariableTreeItem<String> leaf = findLeaf( word );
268
269
    if( leaf != null ) {
270
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
271
      decorateVariable();
272
      expand( leaf );
273
    }
274
  }
275
276
  /**
277
   * Called when autocomplete finishes on a valid leaf or when the user presses
278
   * Enter to finish manual autocomplete.
279
   */
280
  private void decorateVariable() {
281
    // A little bit of duplication...
282
    final String paragraph = getCaretParagraph();
283
    final int[] boundaries = getWordBoundaries( paragraph );
284
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
285
286
    final String newVariable = getVariableDecorator().decorate( old );
287
288
    final int posEnded = getCurrentCaretPosition();
289
    final int posBegan = posEnded - old.length();
290
291
    getEditor().replaceText( posBegan, posEnded, newVariable );
292
  }
293
294
  /**
295
   * Updates the text at the given position within the current paragraph.
296
   *
297
   * @param posBegan The starting index in the paragraph text to replace.
298
   * @param posEnded The ending index in the paragraph text to replace.
299
   * @param text Overwrite the paragraph substring with this text.
300
   */
301
  private void replaceText(
302
    final int posBegan, final int posEnded, final String text ) {
303
    final int p = getCurrentParagraph();
304
305
    getEditor().replaceText( p, posBegan, p, posEnded, text );
306
  }
307
308
  /**
309
   * Returns the caret's current paragraph position.
310
   *
311
   * @return A number greater than or equal to 0.
312
   */
313
  private int getCurrentParagraph() {
314
    return getEditor().getCurrentParagraph();
315
  }
316
317
  /**
318
   * Returns current word boundary indexes into the current paragraph, including
319
   * punctuation.
320
   *
321
   * @param p The paragraph wherein to hunt word boundaries.
322
   * @param offset The offset into the paragraph to begin scanning left and
323
   * right.
324
   *
325
   * @return The starting and ending index of the word closest to the caret.
326
   */
327
  private int[] getWordBoundaries( final String p, final int offset ) {
328
    // Remove dashes, but retain hyphens. Retain same number of characters
329
    // to preserve relative indexes.
330
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
331
332
    return getWordAt( paragraph, offset );
333
  }
334
335
  /**
336
   * Helper method to get the word boundaries for the current paragraph.
337
   *
338
   * @param paragraph
339
   *
340
   * @return
341
   */
342
  private int[] getWordBoundaries( final String paragraph ) {
343
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
344
  }
345
346
  /**
347
   * Given an arbitrary offset into a string, this returns the word at that
348
   * index. The inputs and outputs include:
349
   *
350
   * <ul>
351
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
352
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
353
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
354
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
355
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
356
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
357
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
358
   * </ul>
359
   *
360
   * @param p The string to scan for a word.
361
   * @param offset The offset within s to begin searching for the nearest word
362
   * boundary, must not be out of bounds of s.
363
   *
364
   * @return The word in s at the offset.
365
   *
366
   * @see getWordBegan( String, int )
367
   * @see getWordEnded( String, int )
368
   */
369
  private int[] getWordAt( final String p, final int offset ) {
370
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
371
  }
372
373
  /**
374
   * Returns the index into s where a word begins.
375
   *
376
   * @param s Never null.
377
   * @param offset Index into s to begin searching backwards for a word
378
   * boundary.
379
   *
380
   * @return The index where a word begins.
381
   */
382
  private int getWordBegan( final String s, int offset ) {
383
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
384
      offset--;
385
    }
386
387
    return offset;
388
  }
389
390
  /**
391
   * Returns the index into s where a word ends.
392
   *
393
   * @param s Never null.
394
   * @param offset Index into s to begin searching forwards for a word boundary.
395
   *
396
   * @return The index where a word ends.
397
   */
398
  private int getWordEnded( final String s, int offset ) {
399
    final int length = s.length();
400
401
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
402
      offset++;
403
    }
404
405
    return offset;
406
  }
407
408
  /**
409
   * Returns true if the given character can be reasonably expected to be part
410
   * of a word, including punctuation marks.
411
   *
412
   * @param c The character to compare.
413
   *
414
   * @return false The character is a space character.
415
   */
416
  private boolean isBoundary( final char c ) {
417
    return !isSpaceChar( c );
418
  }
419
420
  /**
421
   * Returns the text for the paragraph that contains the caret.
422
   *
423
   * @return A non-null string, possibly empty.
424
   */
425
  private String getCaretParagraph() {
426
    return getEditor().getText( getCurrentParagraph() );
427
  }
428
429
  /**
430
   * Returns true if the node has children that can be selected (i.e., any
431
   * non-leaves).
432
   *
433
   * @param <T> The type that the TreeItem contains.
434
   * @param node The node to test for terminality.
435
   *
436
   * @return true The node has one branch and its a leaf.
437
   */
438
  private <T> boolean isTerminal( final TreeItem<T> node ) {
439
    final ObservableList<TreeItem<T>> branches = node.getChildren();
440
441
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
442
  }
443
444
  /**
445
   * Inserts text that the user typed at the current caret position, then
446
   * performs an autocomplete for the variable name.
447
   *
448
   * @param text The text to insert, never null.
449
   */
450
  private void typed( final String text ) {
451
    getEditor().replaceSelection( text );
452
    vModeAutocomplete();
453
  }
454
455
  /**
456
   * Called when the user presses either End or Enter key.
457
   */
458
  private void acceptPath() {
459
    final IndexRange range = getSelectionRange();
460
461
    if( range != null ) {
462
      final int rangeEnd = range.getEnd();
463
      final StyledTextArea textArea = getEditor();
464
      textArea.deselect();
465
      textArea.moveTo( rangeEnd );
466
    }
467
  }
468
469
  /**
470
   * Replaces the entirety of the existing path (from the initial caret
471
   * position) with the given path.
472
   *
473
   * @param oldPath The path to replace.
474
   * @param newPath The replacement path.
475
   */
476
  private void replacePath( final String oldPath, final String newPath ) {
477
    final StyledTextArea textArea = getEditor();
478
    final int posBegan = getInitialCaretPosition();
479
    final int posEnded = posBegan + oldPath.length();
480
481
    textArea.deselect();
482
    textArea.replaceText( posBegan, posEnded, newPath );
483
  }
484
485
  /**
486
   * Called when the user presses the Backspace key.
487
   */
488
  private void deleteSelection() {
489
    final StyledTextArea textArea = getEditor();
490
    textArea.replaceSelection( "" );
491
    textArea.deletePreviousChar();
492
  }
493
494
  /**
495
   * Cycles the selected text through the nodes.
496
   *
497
   * @param direction true - next; false - previous
498
   */
499
  private void cycleSelection( final boolean direction ) {
500
    final TreeItem<String> node = getCurrentNode();
501
502
    // Find the sibling for the current selection and replace the current
503
    // selection with the sibling's value
504
    TreeItem< String> cycled = direction
505
      ? node.nextSibling()
506
      : node.previousSibling();
507
508
    // When cycling at the end (or beginning) of the list, jump to the first
509
    // (or last) sibling depending on the cycle direction.
510
    if( cycled == null ) {
511
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
512
    }
513
514
    final String path = getCurrentPath();
515
    final String cycledWord = cycled.getValue();
516
    final String word = getLastPathWord();
517
    final int index = path.indexOf( word );
518
    final String cycledPath = path.substring( 0, index ) + cycledWord;
519
520
    expand( cycled );
521
    replacePath( path, cycledPath );
522
  }
523
524
  /**
525
   * Cycles to the next sibling of the currently selected tree node.
526
   */
527
  private void cyclePathNext() {
528
    cycleSelection( true );
529
  }
530
531
  /**
532
   * Cycles to the previous sibling of the currently selected tree node.
533
   */
534
  private void cyclePathPrev() {
535
    cycleSelection( false );
536
  }
537
538
  /**
539
   * Returns the variable name (or as much as has been typed so far). Returns
540
   * all the characters from the initial caret column to the the first
541
   * whitespace character. This will return a path that contains zero or more
542
   * separators.
543
   *
544
   * @return A non-null string, possibly empty.
545
   */
546
  private String getCurrentPath() {
547
    final String s = extractTextChunk();
548
    final int length = s.length();
549
550
    int i = 0;
551
552
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
553
      i++;
554
    }
555
556
    return s.substring( 0, i );
557
  }
558
559
  private <T> ObservableList<TreeItem<T>> getSiblings(
560
    final TreeItem<T> item ) {
561
    final TreeItem<T> parent = item.getParent();
562
    return parent == null ? item.getChildren() : parent.getChildren();
563
  }
564
565
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
566
    return getFirst( getSiblings( item ), item );
567
  }
568
569
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
570
    return getLast( getSiblings( item ), item );
571
  }
572
573
  /**
574
   * Returns the caret position as an offset into the text.
575
   *
576
   * @return A value from 0 to the length of the text (minus one).
577
   */
578
  private int getCurrentCaretPosition() {
579
    return getEditor().getCaretPosition();
580
  }
581
582
  /**
583
   * Returns the caret position within the current paragraph.
584
   *
585
   * @return A value from 0 to the length of the current paragraph.
586
   */
587
  private int getCurrentCaretColumn() {
588
    return getEditor().getCaretColumn();
589
  }
590
591
  /**
592
   * Returns the last word from the path.
593
   *
594
   * @return The last token.
595
   */
596
  private String getLastPathWord() {
597
    String path = getCurrentPath();
598
599
    int i = path.indexOf( SEPARATOR_CHAR );
600
601
    while( i > 0 ) {
602
      path = path.substring( i + 1 );
603
      i = path.indexOf( SEPARATOR_CHAR );
604
    }
605
606
    return path;
607
  }
608
609
  /**
610
   * Returns text from the initial caret position until some arbitrarily long
611
   * number of characters. The number of characters extracted will be
612
   * getMaxVarLength, or fewer, depending on how many characters remain to be
613
   * extracted. The result from this method is trimmed to the first whitespace
614
   * character.
615
   *
616
   * @return A chunk of text that includes all the words representing a path,
617
   * and then some.
618
   */
619
  private String extractTextChunk() {
620
    final StyledTextArea textArea = getEditor();
621
    final int textBegan = getInitialCaretPosition();
622
    final int remaining = textArea.getLength() - textBegan;
623
    final int textEnded = min( remaining, getMaxVarLength() );
624
625
    try {
626
      return textArea.getText( textBegan, textEnded );
627
    } catch( final Exception e ) {
628
      return textArea.getText();
629
    }
630
  }
631
632
  /**
633
   * Returns the node for the current path.
634
   */
635
  private TreeItem<String> getCurrentNode() {
636
    return findNode( getCurrentPath() );
637
  }
638
639
  /**
640
   * Finds the node that most closely matches the given path.
641
   *
642
   * @param path The path that represents a node.
643
   *
644
   * @return The node for the path, or the root node if the path could not be
645
   * found, but never null.
646
   */
647
  private TreeItem<String> findNode( final String path ) {
648
    return getDefinitionPane().findNode( path );
649
  }
650
651
  /**
652
   * Finds the first leaf having a value that starts with the given text.
653
   *
654
   * @param text The text to find in the definition tree.
655
   *
656
   * @return The leaf that starts with the given text, or null if not found.
657
   */
658
  private VariableTreeItem<String> findLeaf( final String text ) {
659
    return getDefinitionPane().findLeaf( text );
660
  }
661
662
  /**
663
   * Used to ignore typed keys in favour of trapping pressed keys.
664
   *
665
   * @param e The key that was typed.
666
   */
667
  private void vModeKeyTyped( KeyEvent e ) {
668
    e.consume();
669
  }
670
671
  /**
672
   * Used to lazily initialize the keyboard map.
673
   *
674
   * @return Mappings for keyTyped and keyPressed.
675
   */
676
  protected InputMap<InputEvent> createKeyboardMap() {
677
    return sequence(
678
      consume( keyTyped(), this::vModeKeyTyped ),
679
      consume( keyPressed(), this::vModeKeyPressed )
680
    );
681
  }
682
683
  private InputMap<InputEvent> getKeyboardMap() {
684
    if( this.keyboardMap == null ) {
685
      this.keyboardMap = createKeyboardMap();
686
    }
687
688
    return this.keyboardMap;
689
  }
690
691
  /**
692
   * Collapses the tree then expands and selects the given node.
693
   *
694
   * @param node The node to expand.
695
   */
696
  private void expand( final TreeItem<String> node ) {
697
    final DefinitionPane pane = getDefinitionPane();
698
    pane.collapse();
699
    pane.expand( node );
700
    pane.select( node );
701
  }
702
703
  /**
704
   * Returns true iff the key code the user typed can be used as part of a YAML
705
   * variable name.
706
   *
707
   * @param keyEvent Keyboard key press event information.
708
   *
709
   * @return true The key is a value that can be inserted into the text.
710
   */
711
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
712
    final KeyCode kc = keyEvent.getCode();
713
714
    return (kc.isLetterKey()
715
      || kc.isDigitKey()
716
      || (keyEvent.isShiftDown() && kc == MINUS))
717
      && !keyEvent.isControlDown();
718
  }
719
720
  /**
721
   * Starts to capture user input events.
722
   */
723
  private void vModeStart() {
724
    addEventListener( getKeyboardMap() );
725
  }
726
727
  /**
728
   * Restores capturing of user input events to the previous event listener.
729
   * Also asks the processing chain to modify the variable text into a
730
   * machine-readable variable based on the format required by the file type.
731
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
732
   * file (.Rmd, .Rxml) will use `r#xVAR`.
733
   */
734
  private void vModeStop() {
735
    removeEventListener( getKeyboardMap() );
736
  }
737
738
  /**
739
   * Returns a variable decorator that corresponds to the given file type.
740
   *
741
   * @return
742
   */
743
  private VariableDecorator getVariableDecorator() {
744
    return VariableNameDecoratorFactory.newInstance( getFilename() );
745
  }
746
747
  private Path getFilename() {
748
    return getFileEditorTab().getPath();
741749
  }
742750
M src/main/java/com/scrivenvar/processors/ProcessorFactory.java
2929
3030
import com.scrivenvar.AbstractFileFactory;
31
import com.scrivenvar.Constants;
3231
import com.scrivenvar.FileEditorTab;
33
import com.scrivenvar.FileType;
3432
import com.scrivenvar.preview.HTMLPreviewPane;
3533
import java.nio.file.Path;
...
7472
  public Processor<String> createProcessor( final FileEditorTab tab ) {
7573
    final Path path = tab.getPath();
76
    final FileType fileType = lookup( path, Constants.GLOB_PREFIX_FILE );
7774
    Processor<String> processor = null;
7875
79
    switch( fileType ) {
76
    switch( lookup( path ) ) {
8077
      case RMARKDOWN:
8178
        processor = createRProcessor( tab );