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