| 130 | 130 | |
| 131 | 131 | /** |
| 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 ); |
| 872 | 883 | } |
| 873 | 884 | |