Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
3838
}
3939
40
version = '1.3.3'
40
version = '1.3.4'
4141
applicationName = 'scrivenvar'
4242
mainClassName = 'com.scrivenvar.Main'
M src/main/java/com/scrivenvar/FileEditorTab.java
431431
    final EventPattern<? super T, ? extends U> event,
432432
    final Consumer<? super U> consumer ) {
433
    getEditorPane().addEventListener( event, consumer );
433
    getEditorPane().addKeyboardListener( event, consumer );
434434
  }
435435
M src/main/java/com/scrivenvar/MainWindow.java
230230
              initTextChangeListener( tab );
231231
              initCaretParagraphListener( tab );
232
              initKeyboardEventListeners( tab );
232233
//              initSyntaxListener( tab );
233234
            }
...
272273
      }
273274
    );
275
  }
276
277
  /**
278
   * Ensure that the keyboard events are received when a new tab is added
279
   * to the user interface.
280
   *
281
   * @param tab The tab that can trigger keyboard events, such as control+space.
282
   */
283
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
284
    final VariableNameInjector vin = getVariableNameInjector();
285
    vin.initKeyboardEventListeners( tab );
274286
  }
275287
M src/main/java/com/scrivenvar/editors/EditorPane.java
129129
   * @param consumer The method to call when the event happens.
130130
   */
131
  public <T extends Event, U extends T> void addEventListener(
131
  public <T extends Event, U extends T> void addKeyboardListener(
132132
    final EventPattern<? super T, ? extends U> event,
133133
    final Consumer<? super U> consumer ) {
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
130130
131131
  /**
132
   * Traps keys for performing various short-cut tasks, such as @-mode variable
133
   * insertion and control+space for variable autocomplete.
134
   *
135
   * @ key is pressed, a new keyboard map is inserted in place of the current
136
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
137
   *
138
   * @see createKeyboardMap()
139
   */
140
  private void initKeyboardEventListeners() {
141
    // Control and space are pressed.
142
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
143
144
    // @ key in Linux?
145
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
146
    // @ key in Windows.
147
    addEventListener( keyPressed( AT ), this::vMode );
148
  }
149
150
  /**
151
   * The @ symbol is a short-cut to inserting a YAML variable reference.
152
   *
153
   * @param e Superfluous information about the key that was pressed.
154
   */
155
  private void vMode( KeyEvent e ) {
156
    setInitialCaretPosition();
157
    vModeStart();
158
    vModeAutocomplete();
159
  }
160
161
  /**
162
   * Receives key presses until the user completes the variable selection. This
163
   * allows the arrow keys to be used for selecting variables.
164
   *
165
   * @param e The key that was pressed.
166
   */
167
  private void vModeKeyPressed( KeyEvent e ) {
168
    final KeyCode keyCode = e.getCode();
169
170
    switch( keyCode ) {
171
      case BACK_SPACE:
172
        // Don't decorate the variable upon exiting vMode.
173
        vModeBackspace();
174
        break;
175
176
      case ESCAPE:
177
        // Don't decorate the variable upon exiting vMode.
178
        vModeStop();
179
        break;
180
181
      case ENTER:
182
      case PERIOD:
183
      case RIGHT:
184
      case END:
185
        // Stop at a leaf node, ENTER means accept.
186
        if( vModeConditionalComplete() && keyCode == ENTER ) {
187
          vModeStop();
188
189
          // Decorate the variable upon exiting vMode.
190
          decorate();
191
        }
192
        break;
193
194
      case UP:
195
        cyclePathPrev();
196
        break;
197
198
      case DOWN:
199
        cyclePathNext();
200
        break;
201
202
      default:
203
        vModeFilterKeyPressed( e );
204
        break;
205
    }
206
207
    e.consume();
208
  }
209
210
  private void vModeBackspace() {
211
    deleteSelection();
212
213
    // Break out of variable mode by back spacing to the original position.
214
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
215
      vModeAutocomplete();
216
    }
217
    else {
218
      vModeStop();
219
    }
220
  }
221
222
  /**
223
   * Updates the text with the path selected (or typed) by the user.
224
   */
225
  private void vModeAutocomplete() {
226
    final TreeItem<String> node = getCurrentNode();
227
228
    if( node != null && !node.isLeaf() ) {
229
      final String word = getLastPathWord();
230
      final String label = node.getValue();
231
      final int delta = difference( label, word );
232
      final String remainder = delta == NO_DIFFERENCE
233
        ? label
234
        : label.substring( delta );
235
236
      final StyledTextArea textArea = getEditor();
237
      final int posBegan = getCurrentCaretPosition();
238
      final int posEnded = posBegan + remainder.length();
239
240
      textArea.replaceSelection( remainder );
241
242
      if( posEnded - posBegan > 0 ) {
243
        textArea.selectRange( posEnded, posBegan );
244
      }
245
246
      expand( node );
247
    }
248
  }
249
250
  /**
251
   * Only variable name keys can pass through the filter. This is called when
252
   * the user presses a key.
253
   *
254
   * @param e The key that was pressed.
255
   */
256
  private void vModeFilterKeyPressed( final KeyEvent e ) {
257
    if( isVariableNameKey( e ) ) {
258
      typed( e.getText() );
259
    }
260
  }
261
262
  /**
263
   * Performs an autocomplete depending on whether the user has finished typing
264
   * in a word. If there is a selected range, then this will complete the most
265
   * recent word and jump to the next child.
266
   *
267
   * @return true The auto-completed node was a terminal node.
268
   */
269
  private boolean vModeConditionalComplete() {
270
    acceptPath();
271
272
    final TreeItem<String> node = getCurrentNode();
273
    final boolean terminal = isTerminal( node );
274
275
    if( !terminal ) {
276
      typed( SEPARATOR );
277
    }
278
279
    return terminal;
280
  }
281
282
  /**
283
   * Pressing control+space will find a node that matches the current word and
284
   * substitute the YAML variable reference. This is called when the user is not
285
   * editing in vMode.
286
   *
287
   * @param e Ignored -- it can only be Ctrl+Space.
288
   */
289
  private void autocomplete( final KeyEvent e ) {
290
    final String paragraph = getCaretParagraph();
291
    final int[] boundaries = getWordBoundaries( paragraph );
292
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
293
294
    VariableTreeItem<String> leaf = findLeaf( word );
295
296
    if( leaf == null ) {
297
      // If a leaf doesn't match using "starts with", then try using "contains".
298
      leaf = findLeaf( word, true );
299
    }
300
301
    if( leaf != null ) {
302
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
303
      decorate();
304
      expand( leaf );
305
    }
306
  }
307
308
  /**
309
   * Called when autocomplete finishes on a valid leaf or when the user presses
310
   * Enter to finish manual autocomplete.
311
   */
312
  private void decorate() {
313
    // A little bit of duplication...
314
    final String paragraph = getCaretParagraph();
315
    final int[] boundaries = getWordBoundaries( paragraph );
316
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
317
318
    final String newVariable = decorate( old );
319
320
    final int posEnded = getCurrentCaretPosition();
321
    final int posBegan = posEnded - old.length();
322
323
    getEditor().replaceText( posBegan, posEnded, newVariable );
324
  }
325
326
  /**
327
   * Called when user double-clicks on a tree view item.
328
   *
329
   * @param variable The variable to decorate.
330
   */
331
  private String decorate( final String variable ) {
332
    return getVariableDecorator().decorate( variable );
333
  }
334
335
  /**
336
   * Inserts the given string at the current caret position, or replaces
337
   * selected text (if any).
338
   *
339
   * @param s The string to inject.
340
   */
341
  private void replaceSelection( final String s ) {
342
    getEditor().replaceSelection( s );
343
  }
344
345
  /**
346
   * Updates the text at the given position within the current paragraph.
347
   *
348
   * @param posBegan The starting index in the paragraph text to replace.
349
   * @param posEnded The ending index in the paragraph text to replace.
350
   * @param text Overwrite the paragraph substring with this text.
351
   */
352
  private void replaceText(
353
    final int posBegan, final int posEnded, final String text ) {
354
    final int p = getCurrentParagraph();
355
356
    getEditor().replaceText( p, posBegan, p, posEnded, text );
357
  }
358
359
  /**
360
   * Returns the caret's current paragraph position.
361
   *
362
   * @return A number greater than or equal to 0.
363
   */
364
  private int getCurrentParagraph() {
365
    return getEditor().getCurrentParagraph();
366
  }
367
368
  /**
369
   * Returns current word boundary indexes into the current paragraph, excluding
370
   * punctuation.
371
   *
372
   * @param p The paragraph wherein to hunt word boundaries.
373
   * @param offset The offset into the paragraph to begin scanning left and
374
   * right.
375
   *
376
   * @return The starting and ending index of the word closest to the caret.
377
   */
378
  private int[] getWordBoundaries( final String p, final int offset ) {
379
    // Remove dashes, but retain hyphens. Retain same number of characters
380
    // to preserve relative indexes.
381
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
382
383
    return getWordAt( paragraph, offset );
384
  }
385
386
  /**
387
   * Helper method to get the word boundaries for the current paragraph.
388
   *
389
   * @param paragraph
390
   *
391
   * @return
392
   */
393
  private int[] getWordBoundaries( final String paragraph ) {
394
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
395
  }
396
397
  /**
398
   * Given an arbitrary offset into a string, this returns the word at that
399
   * index. The inputs and outputs include:
400
   *
401
   * <ul>
402
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
403
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
404
   * <li>start of a word: <code>hello |world!</code> ("world");</li>
405
   * <li>within a word: <code>hello wo|rld!</code> ("world");</li>
406
   * <li>end of a paragraph: <code>hello world!|</code> ("world");</li>
407
   * <li>start of a paragraph: <code>|hello world!</code> ("hello"); or</li>
408
   * <li>after punctuation: <code>hello world!|</code> ("world").</li>
409
   * </ul>
410
   *
411
   * @param p The string to scan for a word.
412
   * @param offset The offset within s to begin searching for the nearest word
413
   * boundary, must not be out of bounds of s.
414
   *
415
   * @return The word in s at the offset.
416
   *
417
   * @see getWordBegan( String, int )
418
   * @see getWordEnded( String, int )
419
   */
420
  private int[] getWordAt( final String p, final int offset ) {
421
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
422
  }
423
424
  /**
425
   * Returns the index into s where a word begins.
426
   *
427
   * @param s Never null.
428
   * @param offset Index into s to begin searching backwards for a word
429
   * boundary.
430
   *
431
   * @return The index where a word begins.
432
   */
433
  private int getWordBegan( final String s, int offset ) {
434
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
435
      offset--;
436
    }
437
438
    return offset;
439
  }
440
441
  /**
442
   * Returns the index into s where a word ends.
443
   *
444
   * @param s Never null.
445
   * @param offset Index into s to begin searching forwards for a word boundary.
446
   *
447
   * @return The index where a word ends.
448
   */
449
  private int getWordEnded( final String s, int offset ) {
450
    final int length = s.length();
451
452
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
453
      offset++;
454
    }
455
456
    return offset;
457
  }
458
459
  /**
460
   * Returns true if the given character can be reasonably expected to be part
461
   * of a word, including punctuation marks.
462
   *
463
   * @param c The character to compare.
464
   *
465
   * @return false The character is a space character.
466
   */
467
  private boolean isBoundary( final char c ) {
468
    return !isWhitespace( c ) && !isPunctuation( c );
469
  }
470
471
  /**
472
   * Returns true if the given character is part of the set of Latin (English)
473
   * punctuation marks.
474
   *
475
   * @param c
476
   *
477
   * @return
478
   */
479
  private static boolean isPunctuation( final char c ) {
480
    return PUNCTUATION.indexOf( c ) != -1;
481
  }
482
483
  /**
484
   * Returns the text for the paragraph that contains the caret.
485
   *
486
   * @return A non-null string, possibly empty.
487
   */
488
  private String getCaretParagraph() {
489
    return getEditor().getText( getCurrentParagraph() );
490
  }
491
492
  /**
493
   * Returns true if the node has children that can be selected (i.e., any
494
   * non-leaves).
495
   *
496
   * @param <T> The type that the TreeItem contains.
497
   * @param node The node to test for terminality.
498
   *
499
   * @return true The node has one branch and its a leaf.
500
   */
501
  private <T> boolean isTerminal( final TreeItem<T> node ) {
502
    final ObservableList<TreeItem<T>> branches = node.getChildren();
503
504
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
505
  }
506
507
  /**
508
   * Inserts text that the user typed at the current caret position, then
509
   * performs an autocomplete for the variable name.
510
   *
511
   * @param text The text to insert, never null.
512
   */
513
  private void typed( final String text ) {
514
    getEditor().replaceSelection( text );
515
    vModeAutocomplete();
516
  }
517
518
  /**
519
   * Called when the user presses either End or Enter key.
520
   */
521
  private void acceptPath() {
522
    final IndexRange range = getSelectionRange();
523
524
    if( range != null ) {
525
      final int rangeEnd = range.getEnd();
526
      final StyledTextArea textArea = getEditor();
527
      textArea.deselect();
528
      textArea.moveTo( rangeEnd );
529
    }
530
  }
531
532
  /**
533
   * Replaces the entirety of the existing path (from the initial caret
534
   * position) with the given path.
535
   *
536
   * @param oldPath The path to replace.
537
   * @param newPath The replacement path.
538
   */
539
  private void replacePath( final String oldPath, final String newPath ) {
540
    final StyledTextArea textArea = getEditor();
541
    final int posBegan = getInitialCaretPosition();
542
    final int posEnded = posBegan + oldPath.length();
543
544
    textArea.deselect();
545
    textArea.replaceText( posBegan, posEnded, newPath );
546
  }
547
548
  /**
549
   * Called when the user presses the Backspace key.
550
   */
551
  private void deleteSelection() {
552
    final StyledTextArea textArea = getEditor();
553
    textArea.replaceSelection( "" );
554
    textArea.deletePreviousChar();
555
  }
556
557
  /**
558
   * Cycles the selected text through the nodes.
559
   *
560
   * @param direction true - next; false - previous
561
   */
562
  private void cycleSelection( final boolean direction ) {
563
    final TreeItem<String> node = getCurrentNode();
564
565
    // Find the sibling for the current selection and replace the current
566
    // selection with the sibling's value
567
    TreeItem< String> cycled = direction
568
      ? node.nextSibling()
569
      : node.previousSibling();
570
571
    // When cycling at the end (or beginning) of the list, jump to the first
572
    // (or last) sibling depending on the cycle direction.
573
    if( cycled == null ) {
574
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
575
    }
576
577
    final String path = getCurrentPath();
578
    final String cycledWord = cycled.getValue();
579
    final String word = getLastPathWord();
580
    final int index = path.indexOf( word );
581
    final String cycledPath = path.substring( 0, index ) + cycledWord;
582
583
    expand( cycled );
584
    replacePath( path, cycledPath );
585
  }
586
587
  /**
588
   * Cycles to the next sibling of the currently selected tree node.
589
   */
590
  private void cyclePathNext() {
591
    cycleSelection( true );
592
  }
593
594
  /**
595
   * Cycles to the previous sibling of the currently selected tree node.
596
   */
597
  private void cyclePathPrev() {
598
    cycleSelection( false );
599
  }
600
601
  /**
602
   * Returns the variable name (or as much as has been typed so far). Returns
603
   * all the characters from the initial caret column to the the first
604
   * whitespace character. This will return a path that contains zero or more
605
   * separators.
606
   *
607
   * @return A non-null string, possibly empty.
608
   */
609
  private String getCurrentPath() {
610
    final String s = extractTextChunk();
611
    final int length = s.length();
612
613
    int i = 0;
614
615
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
616
      i++;
617
    }
618
619
    return s.substring( 0, i );
620
  }
621
622
  private <T> ObservableList<TreeItem<T>> getSiblings(
623
    final TreeItem<T> item ) {
624
    final TreeItem<T> parent = item.getParent();
625
    return parent == null ? item.getChildren() : parent.getChildren();
626
  }
627
628
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
629
    return getFirst( getSiblings( item ), item );
630
  }
631
632
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
633
    return getLast( getSiblings( item ), item );
634
  }
635
636
  /**
637
   * Returns the caret position as an offset into the text.
638
   *
639
   * @return A value from 0 to the length of the text (minus one).
640
   */
641
  private int getCurrentCaretPosition() {
642
    return getEditor().getCaretPosition();
643
  }
644
645
  /**
646
   * Returns the caret position within the current paragraph.
647
   *
648
   * @return A value from 0 to the length of the current paragraph.
649
   */
650
  private int getCurrentCaretColumn() {
651
    return getEditor().getCaretColumn();
652
  }
653
654
  /**
655
   * Returns the last word from the path.
656
   *
657
   * @return The last token.
658
   */
659
  private String getLastPathWord() {
660
    String path = getCurrentPath();
661
662
    int i = path.indexOf( SEPARATOR_CHAR );
663
664
    while( i > 0 ) {
665
      path = path.substring( i + 1 );
666
      i = path.indexOf( SEPARATOR_CHAR );
667
    }
668
669
    return path;
670
  }
671
672
  /**
673
   * Returns text from the initial caret position until some arbitrarily long
674
   * number of characters. The number of characters extracted will be
675
   * getMaxVarLength, or fewer, depending on how many characters remain to be
676
   * extracted. The result from this method is trimmed to the first whitespace
677
   * character.
678
   *
679
   * @return A chunk of text that includes all the words representing a path,
680
   * and then some.
681
   */
682
  private String extractTextChunk() {
683
    final StyledTextArea textArea = getEditor();
684
    final int textBegan = getInitialCaretPosition();
685
    final int remaining = textArea.getLength() - textBegan;
686
    final int textEnded = min( remaining, getMaxVarLength() );
687
688
    try {
689
      return textArea.getText( textBegan, textEnded );
690
    }
691
    catch( final Exception e ) {
692
      return textArea.getText();
693
    }
694
  }
695
696
  /**
697
   * Returns the node for the current path.
698
   */
699
  private TreeItem<String> getCurrentNode() {
700
    return findNode( getCurrentPath() );
701
  }
702
703
  /**
704
   * Finds the node that most closely matches the given path.
705
   *
706
   * @param path The path that represents a node.
707
   *
708
   * @return The node for the path, or the root node if the path could not be
709
   * found, but never null.
710
   */
711
  private TreeItem<String> findNode( final String path ) {
712
    return getDefinitionPane().findNode( path );
713
  }
714
715
  /**
716
   * Finds the first leaf having a value that starts with the given text.
717
   *
718
   * @param text The text to find in the definition tree.
719
   *
720
   * @return The leaf that starts with the given text, or null if not found.
721
   */
722
  private VariableTreeItem<String> findLeaf( final String text ) {
723
    return getDefinitionPane().findLeaf( text, false );
724
  }
725
726
  /**
727
   * Finds the first leaf having a value that starts with the given text, or
728
   * contains the text if contains is true.
729
   *
730
   * @param text The text to find in the definition tree.
731
   * @param contains Set true to perform a substring match after a starts with
732
   * match.
733
   *
734
   * @return The leaf that starts with the given text, or null if not found.
735
   */
736
  private VariableTreeItem<String> findLeaf(
737
    final String text,
738
    final boolean contains ) {
739
    return getDefinitionPane().findLeaf( text, contains );
740
  }
741
742
  /**
743
   * Used to ignore typed keys in favour of trapping pressed keys.
744
   *
745
   * @param e The key that was typed.
746
   */
747
  private void vModeKeyTyped( KeyEvent e ) {
748
    e.consume();
749
  }
750
751
  /**
752
   * Used to lazily initialize the keyboard map.
753
   *
754
   * @return Mappings for keyTyped and keyPressed.
755
   */
756
  protected InputMap<InputEvent> createKeyboardMap() {
757
    return sequence(
758
      consume( keyTyped(), this::vModeKeyTyped ),
759
      consume( keyPressed(), this::vModeKeyPressed )
760
    );
761
  }
762
763
  private InputMap<InputEvent> getKeyboardMap() {
764
    if( this.keyboardMap == null ) {
765
      this.keyboardMap = createKeyboardMap();
766
    }
767
768
    return this.keyboardMap;
769
  }
770
771
  /**
772
   * Collapses the tree then expands and selects the given node.
773
   *
774
   * @param node The node to expand.
775
   */
776
  private void expand( final TreeItem<String> node ) {
777
    final DefinitionPane pane = getDefinitionPane();
778
    pane.collapse();
779
    pane.expand( node );
780
    pane.select( node );
781
  }
782
783
  /**
784
   * Returns true iff the key code the user typed can be used as part of a YAML
785
   * variable name.
786
   *
787
   * @param keyEvent Keyboard key press event information.
788
   *
789
   * @return true The key is a value that can be inserted into the text.
790
   */
791
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
792
    final KeyCode kc = keyEvent.getCode();
793
794
    return (kc.isLetterKey()
795
      || kc.isDigitKey()
796
      || (keyEvent.isShiftDown() && kc == MINUS))
797
      && !keyEvent.isControlDown();
798
  }
799
800
  /**
801
   * Starts to capture user input events.
802
   */
803
  private void vModeStart() {
804
    addEventListener( getKeyboardMap() );
805
  }
806
807
  /**
808
   * Restores capturing of user input events to the previous event listener.
809
   * Also asks the processing chain to modify the variable text into a
810
   * machine-readable variable based on the format required by the file type.
811
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
812
   * file (.Rmd, .Rxml) will use `r#xVAR`.
813
   */
814
  private void vModeStop() {
815
    removeEventListener( getKeyboardMap() );
816
  }
817
818
  /**
819
   * Returns a variable decorator that corresponds to the given file type.
820
   *
821
   * @return
822
   */
823
  private VariableDecorator getVariableDecorator() {
824
    return VariableNameDecoratorFactory.newInstance( getFilename() );
825
  }
826
827
  private Path getFilename() {
828
    return getFileEditorTab().getPath();
829
  }
830
831
  /**
832
   * Returns the index where the two strings diverge.
833
   *
834
   * @param s1 The string that could be a substring of s2, null allowed.
835
   * @param s2 The string that could be a substring of s1, null allowed.
836
   *
837
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
838
   * where they differ.
839
   */
840
  @SuppressWarnings( "StringEquality" )
841
  private int difference( final CharSequence s1, final CharSequence s2 ) {
842
    if( s1 == s2 ) {
843
      return NO_DIFFERENCE;
844
    }
845
846
    if( s1 == null || s2 == null ) {
847
      return 0;
848
    }
849
850
    int i = 0;
851
    final int limit = min( s1.length(), s2.length() );
852
853
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
854
      i++;
855
    }
856
857
    // If one string was shorter than the other, that's where they differ.
858
    return i;
859
  }
860
861
  private EditorPane getEditorPane() {
862
    return getFileEditorTab().getEditorPane();
863
  }
864
865
  /**
866
   * Delegates to the file editor pane, and, ultimately, to its text area.
867
   */
868
  private <T extends Event, U extends T> void addEventListener(
869
    final EventPattern<? super T, ? extends U> event,
870
    final Consumer<? super U> consumer ) {
871
    getEditorPane().addEventListener( event, consumer );
132
   * Trap control+space and the @ key.
133
   *
134
   * @param tab The file editor that sends keyboard events for variable name
135
   * injection.
136
   */
137
  public void initKeyboardEventListeners( final FileEditorTab tab ) {
138
    setFileEditorTab( tab );
139
    initKeyboardEventListeners();;
140
  }
141
142
  /**
143
   * Traps keys for performing various short-cut tasks, such as @-mode variable
144
   * insertion and control+space for variable autocomplete.
145
   *
146
   * @ key is pressed, a new keyboard map is inserted in place of the current
147
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
148
   *
149
   * @see createKeyboardMap()
150
   */
151
  private void initKeyboardEventListeners() {
152
    // Control and space are pressed.
153
    addKeyboardListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
154
155
    // @ key in Linux?
156
    addKeyboardListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
157
    // @ key in Windows.
158
    addKeyboardListener( keyPressed( AT ), this::vMode );
159
  }
160
161
  /**
162
   * The @ symbol is a short-cut to inserting a YAML variable reference.
163
   *
164
   * @param e Superfluous information about the key that was pressed.
165
   */
166
  private void vMode( KeyEvent e ) {
167
    setInitialCaretPosition();
168
    vModeStart();
169
    vModeAutocomplete();
170
  }
171
172
  /**
173
   * Receives key presses until the user completes the variable selection. This
174
   * allows the arrow keys to be used for selecting variables.
175
   *
176
   * @param e The key that was pressed.
177
   */
178
  private void vModeKeyPressed( KeyEvent e ) {
179
    final KeyCode keyCode = e.getCode();
180
181
    switch( keyCode ) {
182
      case BACK_SPACE:
183
        // Don't decorate the variable upon exiting vMode.
184
        vModeBackspace();
185
        break;
186
187
      case ESCAPE:
188
        // Don't decorate the variable upon exiting vMode.
189
        vModeStop();
190
        break;
191
192
      case ENTER:
193
      case PERIOD:
194
      case RIGHT:
195
      case END:
196
        // Stop at a leaf node, ENTER means accept.
197
        if( vModeConditionalComplete() && keyCode == ENTER ) {
198
          vModeStop();
199
200
          // Decorate the variable upon exiting vMode.
201
          decorate();
202
        }
203
        break;
204
205
      case UP:
206
        cyclePathPrev();
207
        break;
208
209
      case DOWN:
210
        cyclePathNext();
211
        break;
212
213
      default:
214
        vModeFilterKeyPressed( e );
215
        break;
216
    }
217
218
    e.consume();
219
  }
220
221
  private void vModeBackspace() {
222
    deleteSelection();
223
224
    // Break out of variable mode by back spacing to the original position.
225
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
226
      vModeAutocomplete();
227
    }
228
    else {
229
      vModeStop();
230
    }
231
  }
232
233
  /**
234
   * Updates the text with the path selected (or typed) by the user.
235
   */
236
  private void vModeAutocomplete() {
237
    final TreeItem<String> node = getCurrentNode();
238
239
    if( node != null && !node.isLeaf() ) {
240
      final String word = getLastPathWord();
241
      final String label = node.getValue();
242
      final int delta = difference( label, word );
243
      final String remainder = delta == NO_DIFFERENCE
244
        ? label
245
        : label.substring( delta );
246
247
      final StyledTextArea textArea = getEditor();
248
      final int posBegan = getCurrentCaretPosition();
249
      final int posEnded = posBegan + remainder.length();
250
251
      textArea.replaceSelection( remainder );
252
253
      if( posEnded - posBegan > 0 ) {
254
        textArea.selectRange( posEnded, posBegan );
255
      }
256
257
      expand( node );
258
    }
259
  }
260
261
  /**
262
   * Only variable name keys can pass through the filter. This is called when
263
   * the user presses a key.
264
   *
265
   * @param e The key that was pressed.
266
   */
267
  private void vModeFilterKeyPressed( final KeyEvent e ) {
268
    if( isVariableNameKey( e ) ) {
269
      typed( e.getText() );
270
    }
271
  }
272
273
  /**
274
   * Performs an autocomplete depending on whether the user has finished typing
275
   * in a word. If there is a selected range, then this will complete the most
276
   * recent word and jump to the next child.
277
   *
278
   * @return true The auto-completed node was a terminal node.
279
   */
280
  private boolean vModeConditionalComplete() {
281
    acceptPath();
282
283
    final TreeItem<String> node = getCurrentNode();
284
    final boolean terminal = isTerminal( node );
285
286
    if( !terminal ) {
287
      typed( SEPARATOR );
288
    }
289
290
    return terminal;
291
  }
292
293
  /**
294
   * Pressing control+space will find a node that matches the current word and
295
   * substitute the YAML variable reference. This is called when the user is not
296
   * editing in vMode.
297
   *
298
   * @param e Ignored -- it can only be Ctrl+Space.
299
   */
300
  private void autocomplete( final KeyEvent e ) {
301
    final String paragraph = getCaretParagraph();
302
    final int[] boundaries = getWordBoundaries( paragraph );
303
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
304
305
    VariableTreeItem<String> leaf = findLeaf( word );
306
307
    if( leaf == null ) {
308
      // If a leaf doesn't match using "starts with", then try using "contains".
309
      leaf = findLeaf( word, true );
310
    }
311
312
    if( leaf != null ) {
313
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
314
      decorate();
315
      expand( leaf );
316
    }
317
  }
318
319
  /**
320
   * Called when autocomplete finishes on a valid leaf or when the user presses
321
   * Enter to finish manual autocomplete.
322
   */
323
  private void decorate() {
324
    // A little bit of duplication...
325
    final String paragraph = getCaretParagraph();
326
    final int[] boundaries = getWordBoundaries( paragraph );
327
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
328
329
    final String newVariable = decorate( old );
330
331
    final int posEnded = getCurrentCaretPosition();
332
    final int posBegan = posEnded - old.length();
333
334
    getEditor().replaceText( posBegan, posEnded, newVariable );
335
  }
336
337
  /**
338
   * Called when user double-clicks on a tree view item.
339
   *
340
   * @param variable The variable to decorate.
341
   */
342
  private String decorate( final String variable ) {
343
    return getVariableDecorator().decorate( variable );
344
  }
345
346
  /**
347
   * Inserts the given string at the current caret position, or replaces
348
   * selected text (if any).
349
   *
350
   * @param s The string to inject.
351
   */
352
  private void replaceSelection( final String s ) {
353
    getEditor().replaceSelection( s );
354
  }
355
356
  /**
357
   * Updates the text at the given position within the current paragraph.
358
   *
359
   * @param posBegan The starting index in the paragraph text to replace.
360
   * @param posEnded The ending index in the paragraph text to replace.
361
   * @param text Overwrite the paragraph substring with this text.
362
   */
363
  private void replaceText(
364
    final int posBegan, final int posEnded, final String text ) {
365
    final int p = getCurrentParagraph();
366
367
    getEditor().replaceText( p, posBegan, p, posEnded, text );
368
  }
369
370
  /**
371
   * Returns the caret's current paragraph position.
372
   *
373
   * @return A number greater than or equal to 0.
374
   */
375
  private int getCurrentParagraph() {
376
    return getEditor().getCurrentParagraph();
377
  }
378
379
  /**
380
   * Returns current word boundary indexes into the current paragraph, excluding
381
   * punctuation.
382
   *
383
   * @param p The paragraph wherein to hunt word boundaries.
384
   * @param offset The offset into the paragraph to begin scanning left and
385
   * right.
386
   *
387
   * @return The starting and ending index of the word closest to the caret.
388
   */
389
  private int[] getWordBoundaries( final String p, final int offset ) {
390
    // Remove dashes, but retain hyphens. Retain same number of characters
391
    // to preserve relative indexes.
392
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
393
394
    return getWordAt( paragraph, offset );
395
  }
396
397
  /**
398
   * Helper method to get the word boundaries for the current paragraph.
399
   *
400
   * @param paragraph
401
   *
402
   * @return
403
   */
404
  private int[] getWordBoundaries( final String paragraph ) {
405
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
406
  }
407
408
  /**
409
   * Given an arbitrary offset into a string, this returns the word at that
410
   * index. The inputs and outputs include:
411
   *
412
   * <ul>
413
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
414
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
415
   * <li>start of a word: <code>hello |world!</code> ("world");</li>
416
   * <li>within a word: <code>hello wo|rld!</code> ("world");</li>
417
   * <li>end of a paragraph: <code>hello world!|</code> ("world");</li>
418
   * <li>start of a paragraph: <code>|hello world!</code> ("hello"); or</li>
419
   * <li>after punctuation: <code>hello world!|</code> ("world").</li>
420
   * </ul>
421
   *
422
   * @param p The string to scan for a word.
423
   * @param offset The offset within s to begin searching for the nearest word
424
   * boundary, must not be out of bounds of s.
425
   *
426
   * @return The word in s at the offset.
427
   *
428
   * @see getWordBegan( String, int )
429
   * @see getWordEnded( String, int )
430
   */
431
  private int[] getWordAt( final String p, final int offset ) {
432
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
433
  }
434
435
  /**
436
   * Returns the index into s where a word begins.
437
   *
438
   * @param s Never null.
439
   * @param offset Index into s to begin searching backwards for a word
440
   * boundary.
441
   *
442
   * @return The index where a word begins.
443
   */
444
  private int getWordBegan( final String s, int offset ) {
445
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
446
      offset--;
447
    }
448
449
    return offset;
450
  }
451
452
  /**
453
   * Returns the index into s where a word ends.
454
   *
455
   * @param s Never null.
456
   * @param offset Index into s to begin searching forwards for a word boundary.
457
   *
458
   * @return The index where a word ends.
459
   */
460
  private int getWordEnded( final String s, int offset ) {
461
    final int length = s.length();
462
463
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
464
      offset++;
465
    }
466
467
    return offset;
468
  }
469
470
  /**
471
   * Returns true if the given character can be reasonably expected to be part
472
   * of a word, including punctuation marks.
473
   *
474
   * @param c The character to compare.
475
   *
476
   * @return false The character is a space character.
477
   */
478
  private boolean isBoundary( final char c ) {
479
    return !isWhitespace( c ) && !isPunctuation( c );
480
  }
481
482
  /**
483
   * Returns true if the given character is part of the set of Latin (English)
484
   * punctuation marks.
485
   *
486
   * @param c
487
   *
488
   * @return
489
   */
490
  private static boolean isPunctuation( final char c ) {
491
    return PUNCTUATION.indexOf( c ) != -1;
492
  }
493
494
  /**
495
   * Returns the text for the paragraph that contains the caret.
496
   *
497
   * @return A non-null string, possibly empty.
498
   */
499
  private String getCaretParagraph() {
500
    return getEditor().getText( getCurrentParagraph() );
501
  }
502
503
  /**
504
   * Returns true if the node has children that can be selected (i.e., any
505
   * non-leaves).
506
   *
507
   * @param <T> The type that the TreeItem contains.
508
   * @param node The node to test for terminality.
509
   *
510
   * @return true The node has one branch and its a leaf.
511
   */
512
  private <T> boolean isTerminal( final TreeItem<T> node ) {
513
    final ObservableList<TreeItem<T>> branches = node.getChildren();
514
515
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
516
  }
517
518
  /**
519
   * Inserts text that the user typed at the current caret position, then
520
   * performs an autocomplete for the variable name.
521
   *
522
   * @param text The text to insert, never null.
523
   */
524
  private void typed( final String text ) {
525
    getEditor().replaceSelection( text );
526
    vModeAutocomplete();
527
  }
528
529
  /**
530
   * Called when the user presses either End or Enter key.
531
   */
532
  private void acceptPath() {
533
    final IndexRange range = getSelectionRange();
534
535
    if( range != null ) {
536
      final int rangeEnd = range.getEnd();
537
      final StyledTextArea textArea = getEditor();
538
      textArea.deselect();
539
      textArea.moveTo( rangeEnd );
540
    }
541
  }
542
543
  /**
544
   * Replaces the entirety of the existing path (from the initial caret
545
   * position) with the given path.
546
   *
547
   * @param oldPath The path to replace.
548
   * @param newPath The replacement path.
549
   */
550
  private void replacePath( final String oldPath, final String newPath ) {
551
    final StyledTextArea textArea = getEditor();
552
    final int posBegan = getInitialCaretPosition();
553
    final int posEnded = posBegan + oldPath.length();
554
555
    textArea.deselect();
556
    textArea.replaceText( posBegan, posEnded, newPath );
557
  }
558
559
  /**
560
   * Called when the user presses the Backspace key.
561
   */
562
  private void deleteSelection() {
563
    final StyledTextArea textArea = getEditor();
564
    textArea.replaceSelection( "" );
565
    textArea.deletePreviousChar();
566
  }
567
568
  /**
569
   * Cycles the selected text through the nodes.
570
   *
571
   * @param direction true - next; false - previous
572
   */
573
  private void cycleSelection( final boolean direction ) {
574
    final TreeItem<String> node = getCurrentNode();
575
576
    // Find the sibling for the current selection and replace the current
577
    // selection with the sibling's value
578
    TreeItem< String> cycled = direction
579
      ? node.nextSibling()
580
      : node.previousSibling();
581
582
    // When cycling at the end (or beginning) of the list, jump to the first
583
    // (or last) sibling depending on the cycle direction.
584
    if( cycled == null ) {
585
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
586
    }
587
588
    final String path = getCurrentPath();
589
    final String cycledWord = cycled.getValue();
590
    final String word = getLastPathWord();
591
    final int index = path.indexOf( word );
592
    final String cycledPath = path.substring( 0, index ) + cycledWord;
593
594
    expand( cycled );
595
    replacePath( path, cycledPath );
596
  }
597
598
  /**
599
   * Cycles to the next sibling of the currently selected tree node.
600
   */
601
  private void cyclePathNext() {
602
    cycleSelection( true );
603
  }
604
605
  /**
606
   * Cycles to the previous sibling of the currently selected tree node.
607
   */
608
  private void cyclePathPrev() {
609
    cycleSelection( false );
610
  }
611
612
  /**
613
   * Returns the variable name (or as much as has been typed so far). Returns
614
   * all the characters from the initial caret column to the the first
615
   * whitespace character. This will return a path that contains zero or more
616
   * separators.
617
   *
618
   * @return A non-null string, possibly empty.
619
   */
620
  private String getCurrentPath() {
621
    final String s = extractTextChunk();
622
    final int length = s.length();
623
624
    int i = 0;
625
626
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
627
      i++;
628
    }
629
630
    return s.substring( 0, i );
631
  }
632
633
  private <T> ObservableList<TreeItem<T>> getSiblings(
634
    final TreeItem<T> item ) {
635
    final TreeItem<T> parent = item.getParent();
636
    return parent == null ? item.getChildren() : parent.getChildren();
637
  }
638
639
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
640
    return getFirst( getSiblings( item ), item );
641
  }
642
643
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
644
    return getLast( getSiblings( item ), item );
645
  }
646
647
  /**
648
   * Returns the caret position as an offset into the text.
649
   *
650
   * @return A value from 0 to the length of the text (minus one).
651
   */
652
  private int getCurrentCaretPosition() {
653
    return getEditor().getCaretPosition();
654
  }
655
656
  /**
657
   * Returns the caret position within the current paragraph.
658
   *
659
   * @return A value from 0 to the length of the current paragraph.
660
   */
661
  private int getCurrentCaretColumn() {
662
    return getEditor().getCaretColumn();
663
  }
664
665
  /**
666
   * Returns the last word from the path.
667
   *
668
   * @return The last token.
669
   */
670
  private String getLastPathWord() {
671
    String path = getCurrentPath();
672
673
    int i = path.indexOf( SEPARATOR_CHAR );
674
675
    while( i > 0 ) {
676
      path = path.substring( i + 1 );
677
      i = path.indexOf( SEPARATOR_CHAR );
678
    }
679
680
    return path;
681
  }
682
683
  /**
684
   * Returns text from the initial caret position until some arbitrarily long
685
   * number of characters. The number of characters extracted will be
686
   * getMaxVarLength, or fewer, depending on how many characters remain to be
687
   * extracted. The result from this method is trimmed to the first whitespace
688
   * character.
689
   *
690
   * @return A chunk of text that includes all the words representing a path,
691
   * and then some.
692
   */
693
  private String extractTextChunk() {
694
    final StyledTextArea textArea = getEditor();
695
    final int textBegan = getInitialCaretPosition();
696
    final int remaining = textArea.getLength() - textBegan;
697
    final int textEnded = min( remaining, getMaxVarLength() );
698
699
    try {
700
      return textArea.getText( textBegan, textEnded );
701
    }
702
    catch( final Exception e ) {
703
      return textArea.getText();
704
    }
705
  }
706
707
  /**
708
   * Returns the node for the current path.
709
   */
710
  private TreeItem<String> getCurrentNode() {
711
    return findNode( getCurrentPath() );
712
  }
713
714
  /**
715
   * Finds the node that most closely matches the given path.
716
   *
717
   * @param path The path that represents a node.
718
   *
719
   * @return The node for the path, or the root node if the path could not be
720
   * found, but never null.
721
   */
722
  private TreeItem<String> findNode( final String path ) {
723
    return getDefinitionPane().findNode( path );
724
  }
725
726
  /**
727
   * Finds the first leaf having a value that starts with the given text.
728
   *
729
   * @param text The text to find in the definition tree.
730
   *
731
   * @return The leaf that starts with the given text, or null if not found.
732
   */
733
  private VariableTreeItem<String> findLeaf( final String text ) {
734
    return getDefinitionPane().findLeaf( text, false );
735
  }
736
737
  /**
738
   * Finds the first leaf having a value that starts with the given text, or
739
   * contains the text if contains is true.
740
   *
741
   * @param text The text to find in the definition tree.
742
   * @param contains Set true to perform a substring match after a starts with
743
   * match.
744
   *
745
   * @return The leaf that starts with the given text, or null if not found.
746
   */
747
  private VariableTreeItem<String> findLeaf(
748
    final String text,
749
    final boolean contains ) {
750
    return getDefinitionPane().findLeaf( text, contains );
751
  }
752
753
  /**
754
   * Used to ignore typed keys in favour of trapping pressed keys.
755
   *
756
   * @param e The key that was typed.
757
   */
758
  private void vModeKeyTyped( KeyEvent e ) {
759
    e.consume();
760
  }
761
762
  /**
763
   * Used to lazily initialize the keyboard map.
764
   *
765
   * @return Mappings for keyTyped and keyPressed.
766
   */
767
  protected InputMap<InputEvent> createKeyboardMap() {
768
    return sequence(
769
      consume( keyTyped(), this::vModeKeyTyped ),
770
      consume( keyPressed(), this::vModeKeyPressed )
771
    );
772
  }
773
774
  private InputMap<InputEvent> getKeyboardMap() {
775
    if( this.keyboardMap == null ) {
776
      this.keyboardMap = createKeyboardMap();
777
    }
778
779
    return this.keyboardMap;
780
  }
781
782
  /**
783
   * Collapses the tree then expands and selects the given node.
784
   *
785
   * @param node The node to expand.
786
   */
787
  private void expand( final TreeItem<String> node ) {
788
    final DefinitionPane pane = getDefinitionPane();
789
    pane.collapse();
790
    pane.expand( node );
791
    pane.select( node );
792
  }
793
794
  /**
795
   * Returns true iff the key code the user typed can be used as part of a YAML
796
   * variable name.
797
   *
798
   * @param keyEvent Keyboard key press event information.
799
   *
800
   * @return true The key is a value that can be inserted into the text.
801
   */
802
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
803
    final KeyCode kc = keyEvent.getCode();
804
805
    return (kc.isLetterKey()
806
      || kc.isDigitKey()
807
      || (keyEvent.isShiftDown() && kc == MINUS))
808
      && !keyEvent.isControlDown();
809
  }
810
811
  /**
812
   * Starts to capture user input events.
813
   */
814
  private void vModeStart() {
815
    addEventListener( getKeyboardMap() );
816
  }
817
818
  /**
819
   * Restores capturing of user input events to the previous event listener.
820
   * Also asks the processing chain to modify the variable text into a
821
   * machine-readable variable based on the format required by the file type.
822
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
823
   * file (.Rmd, .Rxml) will use `r#xVAR`.
824
   */
825
  private void vModeStop() {
826
    removeEventListener( getKeyboardMap() );
827
  }
828
829
  /**
830
   * Returns a variable decorator that corresponds to the given file type.
831
   *
832
   * @return
833
   */
834
  private VariableDecorator getVariableDecorator() {
835
    return VariableNameDecoratorFactory.newInstance( getFilename() );
836
  }
837
838
  private Path getFilename() {
839
    return getFileEditorTab().getPath();
840
  }
841
842
  /**
843
   * Returns the index where the two strings diverge.
844
   *
845
   * @param s1 The string that could be a substring of s2, null allowed.
846
   * @param s2 The string that could be a substring of s1, null allowed.
847
   *
848
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
849
   * where they differ.
850
   */
851
  @SuppressWarnings( "StringEquality" )
852
  private int difference( final CharSequence s1, final CharSequence s2 ) {
853
    if( s1 == s2 ) {
854
      return NO_DIFFERENCE;
855
    }
856
857
    if( s1 == null || s2 == null ) {
858
      return 0;
859
    }
860
861
    int i = 0;
862
    final int limit = min( s1.length(), s2.length() );
863
864
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
865
      i++;
866
    }
867
868
    // If one string was shorter than the other, that's where they differ.
869
    return i;
870
  }
871
872
  private EditorPane getEditorPane() {
873
    return getFileEditorTab().getEditorPane();
874
  }
875
876
  /**
877
   * Delegates to the file editor pane, and, ultimately, to its text area.
878
   */
879
  private <T extends Event, U extends T> void addKeyboardListener(
880
    final EventPattern<? super T, ? extends U> event,
881
    final Consumer<? super U> consumer ) {
882
    getEditorPane().addKeyboardListener( event, consumer );
872883
  }
873884
M src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
7070
    textArea.getStylesheets().add( STYLESHEET_MARKDOWN );
7171
72
    addEventListener( keyPressed( ENTER ), this::enterPressed );
72
    addKeyboardListener( keyPressed( ENTER ), this::enterPressed );
7373
  }
7474