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