Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
1
version = '1.0.13'
1
version = '1.0.14'
22
33
apply plugin: 'java'
M src/main/java/com/scrivenvar/Constants.java
2929
3030
import com.scrivenvar.service.Settings;
31
import java.util.Collection;
3132
3233
/**
...
4950
  private static int get( final String key, int defaultValue ) {
5051
    return SETTINGS.getSetting( key, defaultValue );
52
  }
53
54
  private static Collection<String> getStringSettingList( final String key ) {
55
    return SETTINGS.getStringSettingList( key );
5156
  }
5257
...
8388
  public static final String GLOB_PREFIX_FILE = "file.ext";
8489
  public static final String GLOB_PREFIX_DEFINITION = "definition." + GLOB_PREFIX_FILE;
90
91
  public static final Collection<String> GLOB_DEFINITION_EXTENSIONS
92
    = getStringSettingList( GLOB_PREFIX_FILE + ".definition" );
8593
8694
  // Different definition source protocols.
M src/main/java/com/scrivenvar/FileEditorTab.java
181181
182182
  /**
183
   * Searches from the caret position forward for the given string.
184
   *
185
   * @param needle The text string to match.
186
   */
187
  public void searchNext( final String needle ) {
188
    final String haystack = getEditorText();
189
    final int index = haystack.indexOf( needle, getCaretPosition() );
190
191
    if( index >= 0 ) {
192
      setCaretPosition( index );
193
      getEditor().selectRange( index, index + needle.length() );
194
    }
195
  }
196
197
  /**
183198
   * Returns the index into the text where the caret blinks happily away.
184199
   *
185200
   * @return A number from 0 to the editor's document text length.
186201
   */
187202
  public int getCaretPosition() {
188203
    return getEditor().getCaretPosition();
204
  }
205
206
  /**
207
   * Moves the caret to a given offset.
208
   *
209
   * @param offset The new caret offset.
210
   */
211
  private void setCaretPosition( final int offset ) {
212
    getEditor().moveTo( offset );
213
    getEditor().requestFollowCaret();
189214
  }
190215
191216
  /**
192217
   * Returns the caret's current row and column position.
193
   * 
218
   *
194219
   * @return The caret's offset into the document.
195220
   */
M src/main/java/com/scrivenvar/FileEditorTabPane.java
3535
import com.scrivenvar.service.Settings;
3636
import com.scrivenvar.service.events.Notification;
37
import com.scrivenvar.service.events.Notifier;
3738
import static com.scrivenvar.service.events.Notifier.NO;
3839
import static com.scrivenvar.service.events.Notifier.YES;
...
6768
import org.fxmisc.wellbehaved.event.EventPattern;
6869
import org.fxmisc.wellbehaved.event.InputMap;
69
import com.scrivenvar.service.events.Notifier;
70
import static com.scrivenvar.Messages.get;
7170
7271
/**
M src/main/java/com/scrivenvar/MainWindow.java
3434
import com.scrivenvar.editors.VariableNameInjector;
3535
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
36
import com.scrivenvar.preview.HTMLPreviewPane;
37
import com.scrivenvar.processors.Processor;
38
import com.scrivenvar.processors.ProcessorFactory;
39
import com.scrivenvar.service.Options;
40
import com.scrivenvar.service.Snitch;
41
import com.scrivenvar.service.events.Notifier;
42
import com.scrivenvar.util.Action;
43
import com.scrivenvar.util.ActionUtils;
44
import static com.scrivenvar.util.StageState.*;
45
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
46
import java.nio.file.Path;
47
import java.util.HashMap;
48
import java.util.Map;
49
import java.util.Observable;
50
import java.util.Observer;
51
import java.util.function.Function;
52
import java.util.prefs.Preferences;
53
import javafx.application.Platform;
54
import javafx.beans.binding.Bindings;
55
import javafx.beans.binding.BooleanBinding;
56
import javafx.beans.property.BooleanProperty;
57
import javafx.beans.property.SimpleBooleanProperty;
58
import javafx.beans.value.ObservableBooleanValue;
59
import javafx.beans.value.ObservableValue;
60
import javafx.collections.ListChangeListener.Change;
61
import javafx.collections.ObservableList;
62
import static javafx.event.Event.fireEvent;
63
import javafx.geometry.Pos;
64
import javafx.scene.Node;
65
import javafx.scene.Scene;
66
import javafx.scene.control.Alert;
67
import javafx.scene.control.Alert.AlertType;
68
import javafx.scene.control.Menu;
69
import javafx.scene.control.MenuBar;
70
import javafx.scene.control.SplitPane;
71
import javafx.scene.control.Tab;
72
import javafx.scene.control.ToolBar;
73
import javafx.scene.control.TreeView;
74
import javafx.scene.image.Image;
75
import javafx.scene.image.ImageView;
76
import static javafx.scene.input.KeyCode.ESCAPE;
77
import javafx.scene.input.KeyEvent;
78
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
79
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
80
import javafx.scene.layout.BorderPane;
81
import javafx.scene.layout.VBox;
82
import javafx.scene.text.Text;
83
import javafx.stage.Window;
84
import javafx.stage.WindowEvent;
85
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
86
import org.controlsfx.control.StatusBar;
87
import org.fxmisc.richtext.model.TwoDimensional.Position;
88
89
/**
90
 * Main window containing a tab pane in the center for file editors.
91
 *
92
 * @author Karl Tauber and White Magic Software, Ltd.
93
 */
94
public class MainWindow implements Observer {
95
96
  private final Options options = Services.load( Options.class );
97
  private final Snitch snitch = Services.load( Snitch.class );
98
  private final Notifier notifier = Services.load( Notifier.class );
99
100
  private Scene scene;
101
  private MenuBar menuBar;
102
  private StatusBar statusBar;
103
  private Text lineNumberText;
104
105
  private DefinitionSource definitionSource;
106
  private DefinitionPane definitionPane;
107
  private FileEditorTabPane fileEditorPane;
108
  private HTMLPreviewPane previewPane;
109
110
  /**
111
   * Prevent re-instantiation processing classes.
112
   */
113
  private Map<FileEditorTab, Processor<String>> processors;
114
115
  public MainWindow() {
116
    initLayout();
117
    initDefinitionListener();
118
    initTabAddedListener();
119
    initTabChangedListener();
120
    initPreferences();
121
    initSnitch();
122
  }
123
124
  /**
125
   * Listen for file editor tab pane to receive an open definition source event.
126
   */
127
  private void initDefinitionListener() {
128
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
129
      (ObservableValue<? extends Path> definitionFile,
130
        final Path oldPath, final Path newPath) -> {
131
        openDefinition( newPath );
132
133
        // Indirectly refresh the resolved map.
134
        setProcessors( null );
135
136
        updateDefinitionPane();
137
138
        // Will create new processors and therefore a new resolved map.
139
        refreshSelectedTab( getActiveFileEditor() );
140
      }
141
    );
142
  }
143
144
  /**
145
   * When tabs are added, hook the various change listeners onto the new tab so
146
   * that the preview pane refreshes as necessary.
147
   */
148
  private void initTabAddedListener() {
149
    final FileEditorTabPane editorPane = getFileEditorPane();
150
151
    // Make sure the text processor kicks off when new files are opened.
152
    final ObservableList<Tab> tabs = editorPane.getTabs();
153
154
    // Update the preview pane on tab changes.
155
    tabs.addListener(
156
      (final Change<? extends Tab> change) -> {
157
        while( change.next() ) {
158
          if( change.wasAdded() ) {
159
            // Multiple tabs can be added simultaneously.
160
            for( final Tab newTab : change.getAddedSubList() ) {
161
              final FileEditorTab tab = (FileEditorTab)newTab;
162
163
              initTextChangeListener( tab );
164
              initCaretParagraphListener( tab );
165
              initVariableNameInjector( tab );
166
//              initSyntaxListener( tab );
167
            }
168
          }
169
        }
170
      }
171
    );
172
  }
173
174
  /**
175
   * Reloads the preferences from the previous load.
176
   */
177
  private void initPreferences() {
178
    restoreDefinitionSource();
179
    getFileEditorPane().restorePreferences();
180
    updateDefinitionPane();
181
  }
182
183
  /**
184
   * Listen for new tab selection events.
185
   */
186
  private void initTabChangedListener() {
187
    final FileEditorTabPane editorPane = getFileEditorPane();
188
189
    // Update the preview pane changing tabs.
190
    editorPane.addTabSelectionListener(
191
      (ObservableValue<? extends Tab> tabPane,
192
        final Tab oldTab, final Tab newTab) -> {
193
194
        // If there was no old tab, then this is a first time load, which
195
        // can be ignored.
196
        if( oldTab != null ) {
197
          if( newTab == null ) {
198
            closeRemainingTab();
199
          }
200
          else {
201
            // Update the preview with the edited text.
202
            refreshSelectedTab( (FileEditorTab)newTab );
203
          }
204
        }
205
      }
206
    );
207
  }
208
209
  private void initTextChangeListener( final FileEditorTab tab ) {
210
    tab.addTextChangeListener(
211
      (ObservableValue<? extends String> editor,
212
        final String oldValue, final String newValue) -> {
213
        refreshSelectedTab( tab );
214
      }
215
    );
216
  }
217
218
  private void initCaretParagraphListener( final FileEditorTab tab ) {
219
    tab.addCaretParagraphListener(
220
      (ObservableValue<? extends Integer> editor,
221
        final Integer oldValue, final Integer newValue) -> {
222
        refreshSelectedTab( tab );
223
      }
224
    );
225
  }
226
227
  private void initVariableNameInjector( final FileEditorTab tab ) {
228
    VariableNameInjector.listen( tab, getDefinitionPane() );
229
  }
230
231
  /**
232
   * Watch for changes to external files. In particular, this awaits
233
   * modifications to any XSL files associated with XML files being edited. When
234
   * an XSL file is modified (external to the application), the snitch's ears
235
   * perk up and the file is reloaded. This keeps the XSL transformation up to
236
   * date with what's on the file system.
237
   */
238
  private void initSnitch() {
239
    getSnitch().addObserver( this );
240
  }
241
242
  /**
243
   * Called whenever the preview pane becomes out of sync with the file editor
244
   * tab. This can be called when the text changes, the caret paragraph changes,
245
   * or the file tab changes.
246
   *
247
   * @param tab The file editor tab that has been changed in some fashion.
248
   */
249
  private void refreshSelectedTab( final FileEditorTab tab ) {
250
    if( tab.isFileOpen() ) {
251
      getPreviewPane().setPath( tab.getPath() );
252
253
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
254
      final Position p = tab.getCaretOffset();
255
      getLineNumberText().setText(
256
        get( STATUS_BAR_LINE,
257
          p.getMajor() + 1,
258
          p.getMinor() + 1,
259
          tab.getCaretPosition() + 1
260
        )
261
      );
262
263
      Processor<String> processor = getProcessors().get( tab );
264
265
      if( processor == null ) {
266
        processor = createProcessor( tab );
267
        getProcessors().put( tab, processor );
268
      }
269
270
      try {
271
        getNotifier().clear();
272
        processor.processChain( tab.getEditorText() );
273
      } catch( final Exception ex ) {
274
        error( ex );
275
      }
276
    }
277
  }
278
279
  /**
280
   * Returns the variable map of interpolated definitions.
281
   *
282
   * @return A map to help dereference variables.
283
   */
284
  private Map<String, String> getResolvedMap() {
285
    return getDefinitionSource().getResolvedMap();
286
  }
287
288
  /**
289
   * Returns the root node for the hierarchical definition source.
290
   *
291
   * @return Data to display in the definition pane.
292
   */
293
  private TreeView<String> getTreeView() {
294
    try {
295
      return getDefinitionSource().asTreeView();
296
    } catch( Exception e ) {
297
      error( e );
298
    }
299
300
    // Slightly redundant as getDefinitionSource() might have returned an
301
    // empty definition source.
302
    return (new EmptyDefinitionSource()).asTreeView();
303
  }
304
305
  /**
306
   * Called when a definition source is opened.
307
   *
308
   * @param path Path to the definition source that was opened.
309
   */
310
  private void openDefinition( final Path path ) {
311
    try {
312
      final DefinitionSource ds = createDefinitionSource( path.toString() );
313
      setDefinitionSource( ds );
314
      storeDefinitionSource();
315
      updateDefinitionPane();
316
    } catch( final Exception e ) {
317
      error( e );
318
    }
319
  }
320
321
  private void updateDefinitionPane() {
322
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
323
  }
324
325
  private void restoreDefinitionSource() {
326
    final Preferences preferences = getPreferences();
327
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
328
329
    // If there's no definition source set, don't try to load it.
330
    if( source != null ) {
331
      setDefinitionSource( createDefinitionSource( source ) );
332
    }
333
  }
334
335
  private void storeDefinitionSource() {
336
    final Preferences preferences = getPreferences();
337
    final DefinitionSource ds = getDefinitionSource();
338
339
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
340
  }
341
342
  /**
343
   * Called when the last open tab is closed to clear the preview pane.
344
   */
345
  private void closeRemainingTab() {
346
    getPreviewPane().clear();
347
  }
348
349
  /**
350
   * Called when an exception occurs that warrants the user's attention.
351
   *
352
   * @param e The exception with a message that the user should know about.
353
   */
354
  private void error( final Exception e ) {
355
    getNotifier().notify( e );
356
  }
357
358
  //---- File actions -------------------------------------------------------
359
  /**
360
   * Called when an observable instance has changed. This includes the snitch
361
   * service and the notify service.
362
   *
363
   * @param observable The observed instance.
364
   * @param value The noteworthy item.
365
   */
366
  @Override
367
  public void update( final Observable observable, final Object value ) {
368
    if( value != null ) {
369
      if( observable instanceof Snitch && value instanceof Path ) {
370
        update( (Path)value );
371
      }
372
      else if( observable instanceof Notifier && value instanceof String ) {
373
        final String s = (String)value;
374
        final int index = s.indexOf( '\n' );
375
        final String message = s.substring( 0, index > 0 ? index : s.length() );
376
377
        getStatusBar().setText( message );
378
      }
379
    }
380
  }
381
382
  /**
383
   * Called when a file has been modified.
384
   *
385
   * @param file Path to the modified file.
386
   */
387
  private void update( final Path file ) {
388
    // Avoid throwing IllegalStateException by running from a non-JavaFX thread.
389
    Platform.runLater(
390
      () -> {
391
        // Brute-force XSLT file reload by re-instantiating all processors.
392
        resetProcessors();
393
        refreshSelectedTab( getActiveFileEditor() );
394
      }
395
    );
396
  }
397
398
  /**
399
   * After resetting the processors, they will refresh anew to be up-to-date
400
   * with the files (text and definition) currently loaded into the editor.
401
   */
402
  private void resetProcessors() {
403
    getProcessors().clear();
404
  }
405
406
  //---- File actions -------------------------------------------------------
407
  private void fileNew() {
408
    getFileEditorPane().newEditor();
409
  }
410
411
  private void fileOpen() {
412
    getFileEditorPane().openFileDialog();
413
  }
414
415
  private void fileClose() {
416
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
417
  }
418
419
  private void fileCloseAll() {
420
    getFileEditorPane().closeAllEditors();
421
  }
422
423
  private void fileSave() {
424
    getFileEditorPane().saveEditor( getActiveFileEditor() );
425
  }
426
427
  private void fileSaveAll() {
428
    getFileEditorPane().saveAllEditors();
429
  }
430
431
  private void fileExit() {
432
    final Window window = getWindow();
433
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
434
  }
435
436
  //---- Help actions -------------------------------------------------------
437
  private void helpAbout() {
438
    Alert alert = new Alert( AlertType.INFORMATION );
439
    alert.setTitle( get( "Dialog.about.title" ) );
440
    alert.setHeaderText( get( "Dialog.about.header" ) );
441
    alert.setContentText( get( "Dialog.about.content" ) );
442
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
443
    alert.initOwner( getWindow() );
444
445
    alert.showAndWait();
446
  }
447
448
  //---- Convenience accessors ----------------------------------------------
449
  private float getFloat( final String key, final float defaultValue ) {
450
    return getPreferences().getFloat( key, defaultValue );
451
  }
452
453
  private Preferences getPreferences() {
454
    return getOptions().getState();
455
  }
456
457
  protected Scene getScene() {
458
    if( this.scene == null ) {
459
      this.scene = createScene();
460
    }
461
462
    return this.scene;
463
  }
464
465
  public Window getWindow() {
466
    return getScene().getWindow();
467
  }
468
469
  private MarkdownEditorPane getActiveEditor() {
470
    final EditorPane pane = getActiveFileEditor().getEditorPane();
471
472
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
473
  }
474
475
  private FileEditorTab getActiveFileEditor() {
476
    return getFileEditorPane().getActiveFileEditor();
477
  }
478
479
  //---- Member accessors ---------------------------------------------------
480
  private void setScene( Scene scene ) {
481
    this.scene = scene;
482
  }
483
484
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
485
    this.processors = map;
486
  }
487
488
  private Map<FileEditorTab, Processor<String>> getProcessors() {
489
    if( this.processors == null ) {
490
      setProcessors( new HashMap<>() );
491
    }
492
493
    return this.processors;
494
  }
495
496
  private FileEditorTabPane getFileEditorPane() {
497
    if( this.fileEditorPane == null ) {
498
      this.fileEditorPane = createFileEditorPane();
499
    }
500
501
    return this.fileEditorPane;
502
  }
503
504
  private HTMLPreviewPane getPreviewPane() {
505
    if( this.previewPane == null ) {
506
      this.previewPane = createPreviewPane();
507
    }
508
509
    return this.previewPane;
510
  }
511
512
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
513
    this.definitionSource = definitionSource;
514
  }
515
516
  private DefinitionSource getDefinitionSource() {
517
    if( this.definitionSource == null ) {
518
      this.definitionSource = new EmptyDefinitionSource();
519
    }
520
521
    return this.definitionSource;
522
  }
523
524
  private DefinitionPane getDefinitionPane() {
525
    if( this.definitionPane == null ) {
526
      this.definitionPane = createDefinitionPane();
527
    }
528
529
    return this.definitionPane;
530
  }
531
532
  private Options getOptions() {
533
    return this.options;
534
  }
535
536
  private Snitch getSnitch() {
537
    return this.snitch;
538
  }
539
540
  private Notifier getNotifier() {
541
    return this.notifier;
542
  }
543
544
  public void setMenuBar( final MenuBar menuBar ) {
545
    this.menuBar = menuBar;
546
  }
547
548
  public MenuBar getMenuBar() {
549
    return this.menuBar;
550
  }
551
552
  private Text getLineNumberText() {
553
    if( this.lineNumberText == null ) {
554
      this.lineNumberText = createLineNumberText();
555
    }
556
557
    return this.lineNumberText;
558
  }
559
560
  private synchronized StatusBar getStatusBar() {
561
    if( this.statusBar == null ) {
562
      this.statusBar = createStatusBar();
563
    }
564
565
    return this.statusBar;
566
  }
567
568
  //---- Member creators ----------------------------------------------------
569
  /**
570
   * Factory to create processors that are suited to different file types.
571
   *
572
   * @param tab The tab that is subjected to processing.
573
   *
574
   * @return A processor suited to the file type specified by the tab's path.
575
   */
576
  private Processor<String> createProcessor( final FileEditorTab tab ) {
577
    return createProcessorFactory().createProcessor( tab );
578
  }
579
580
  private ProcessorFactory createProcessorFactory() {
581
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
582
  }
583
584
  private DefinitionSource createDefinitionSource( final String path ) {
585
    return createDefinitionFactory().createDefinitionSource( path );
586
  }
587
588
  /**
589
   * Create an editor pane to hold file editor tabs.
590
   *
591
   * @return A new instance, never null.
592
   */
593
  private FileEditorTabPane createFileEditorPane() {
594
    return new FileEditorTabPane();
595
  }
596
597
  private HTMLPreviewPane createPreviewPane() {
598
    return new HTMLPreviewPane();
599
  }
600
601
  private DefinitionPane createDefinitionPane() {
602
    return new DefinitionPane( getTreeView() );
603
  }
604
605
  private DefinitionFactory createDefinitionFactory() {
606
    return new DefinitionFactory();
607
  }
608
609
  private StatusBar createStatusBar() {
610
    return new StatusBar();
611
  }
612
613
  private Scene createScene() {
614
    final SplitPane splitPane = new SplitPane(
615
      getDefinitionPane().getNode(),
616
      getFileEditorPane().getNode(),
617
      getPreviewPane().getNode() );
618
619
    splitPane.setDividerPositions(
620
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
621
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
622
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
623
624
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
625
    final BorderPane borderPane = new BorderPane();
626
    borderPane.setPrefSize( 1024, 800 );
627
    borderPane.setTop( createMenuBar() );
628
    borderPane.setBottom( getStatusBar() );
629
    borderPane.setCenter( splitPane );
630
631
    final VBox box = new VBox();
632
    box.setAlignment( Pos.BASELINE_CENTER );
633
    box.getChildren().add( getLineNumberText() );
634
    getStatusBar().getRightItems().add( box );
635
636
    return new Scene( borderPane );
637
  }
638
639
  private Text createLineNumberText() {
640
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
641
  }
642
643
  private Node createMenuBar() {
644
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
645
646
    // File actions
647
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
648
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
649
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
650
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
651
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
652
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
653
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
654
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
655
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
656
657
    // Edit actions
658
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
659
      e -> getActiveEditor().undo(),
660
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
661
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
662
      e -> getActiveEditor().redo(),
663
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
664
    Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Shortcut+F", SEARCH,
665
      e -> getActiveEditor().find(),
666
      activeFileEditorIsNull );
667
    Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
668
      e -> getActiveEditor().replace(),
669
      activeFileEditorIsNull );
670
    Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
671
      e -> getActiveEditor().findNext(),
36
import com.scrivenvar.predicates.files.FileTypePredicate;
37
import com.scrivenvar.preview.HTMLPreviewPane;
38
import com.scrivenvar.processors.Processor;
39
import com.scrivenvar.processors.ProcessorFactory;
40
import com.scrivenvar.service.Options;
41
import com.scrivenvar.service.Snitch;
42
import com.scrivenvar.service.events.Notifier;
43
import com.scrivenvar.util.Action;
44
import com.scrivenvar.util.ActionUtils;
45
import static com.scrivenvar.util.StageState.*;
46
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
47
import java.io.IOException;
48
import java.nio.file.Path;
49
import java.util.HashMap;
50
import java.util.Map;
51
import java.util.Observable;
52
import java.util.Observer;
53
import java.util.function.Function;
54
import java.util.prefs.Preferences;
55
import javafx.application.Platform;
56
import javafx.beans.binding.Bindings;
57
import javafx.beans.binding.BooleanBinding;
58
import javafx.beans.property.BooleanProperty;
59
import javafx.beans.property.SimpleBooleanProperty;
60
import javafx.beans.value.ObservableBooleanValue;
61
import javafx.beans.value.ObservableValue;
62
import javafx.collections.ListChangeListener.Change;
63
import javafx.collections.ObservableList;
64
import static javafx.event.Event.fireEvent;
65
import javafx.geometry.Pos;
66
import javafx.scene.Node;
67
import javafx.scene.Scene;
68
import javafx.scene.control.Alert;
69
import javafx.scene.control.Alert.AlertType;
70
import javafx.scene.control.Menu;
71
import javafx.scene.control.MenuBar;
72
import javafx.scene.control.SplitPane;
73
import javafx.scene.control.Tab;
74
import javafx.scene.control.TextField;
75
import javafx.scene.control.ToolBar;
76
import javafx.scene.control.TreeView;
77
import javafx.scene.image.Image;
78
import javafx.scene.image.ImageView;
79
import static javafx.scene.input.KeyCode.ESCAPE;
80
import javafx.scene.input.KeyEvent;
81
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
82
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
83
import javafx.scene.layout.BorderPane;
84
import javafx.scene.layout.VBox;
85
import javafx.scene.text.Text;
86
import javafx.stage.Window;
87
import javafx.stage.WindowEvent;
88
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
89
import org.controlsfx.control.StatusBar;
90
import org.fxmisc.richtext.model.TwoDimensional.Position;
91
92
/**
93
 * Main window containing a tab pane in the center for file editors.
94
 *
95
 * @author Karl Tauber and White Magic Software, Ltd.
96
 */
97
public class MainWindow implements Observer {
98
99
  private final Options options = Services.load( Options.class );
100
  private final Snitch snitch = Services.load( Snitch.class );
101
  private final Notifier notifier = Services.load( Notifier.class );
102
103
  private Scene scene;
104
  private MenuBar menuBar;
105
  private StatusBar statusBar;
106
  private Text lineNumberText;
107
108
  private DefinitionSource definitionSource;
109
  private DefinitionPane definitionPane;
110
  private FileEditorTabPane fileEditorPane;
111
  private HTMLPreviewPane previewPane;
112
113
  /**
114
   * Prevent re-instantiation processing classes.
115
   */
116
  private Map<FileEditorTab, Processor<String>> processors;
117
118
  public MainWindow() {
119
    initLayout();
120
    initSnitch();
121
    initDefinitionListener();
122
    initTabAddedListener();
123
    initTabChangedListener();
124
    initPreferences();
125
  }
126
127
  /**
128
   * Listen for file editor tab pane to receive an open definition source event.
129
   */
130
  private void initDefinitionListener() {
131
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
132
      (ObservableValue<? extends Path> definitionFile,
133
        final Path oldPath, final Path newPath) -> {
134
        openDefinition( newPath );
135
136
        // Indirectly refresh the resolved map.
137
        setProcessors( null );
138
139
        updateDefinitionPane();
140
141
        try {
142
          getSnitch().ignore( oldPath );
143
          getSnitch().listen( newPath );
144
        } catch( final IOException ex ) {
145
          error( ex );
146
        }
147
148
        // Will create new processors and therefore a new resolved map.
149
        refreshSelectedTab( getActiveFileEditor() );
150
      }
151
    );
152
  }
153
154
  /**
155
   * When tabs are added, hook the various change listeners onto the new tab so
156
   * that the preview pane refreshes as necessary.
157
   */
158
  private void initTabAddedListener() {
159
    final FileEditorTabPane editorPane = getFileEditorPane();
160
161
    // Make sure the text processor kicks off when new files are opened.
162
    final ObservableList<Tab> tabs = editorPane.getTabs();
163
164
    // Update the preview pane on tab changes.
165
    tabs.addListener(
166
      (final Change<? extends Tab> change) -> {
167
        while( change.next() ) {
168
          if( change.wasAdded() ) {
169
            // Multiple tabs can be added simultaneously.
170
            for( final Tab newTab : change.getAddedSubList() ) {
171
              final FileEditorTab tab = (FileEditorTab)newTab;
172
173
              initTextChangeListener( tab );
174
              initCaretParagraphListener( tab );
175
              initVariableNameInjector( tab );
176
//              initSyntaxListener( tab );
177
            }
178
          }
179
        }
180
      }
181
    );
182
  }
183
184
  /**
185
   * Reloads the preferences from the previous load.
186
   */
187
  private void initPreferences() {
188
    restoreDefinitionSource();
189
    getFileEditorPane().restorePreferences();
190
    updateDefinitionPane();
191
  }
192
193
  /**
194
   * Listen for new tab selection events.
195
   */
196
  private void initTabChangedListener() {
197
    final FileEditorTabPane editorPane = getFileEditorPane();
198
199
    // Update the preview pane changing tabs.
200
    editorPane.addTabSelectionListener(
201
      (ObservableValue<? extends Tab> tabPane,
202
        final Tab oldTab, final Tab newTab) -> {
203
204
        // If there was no old tab, then this is a first time load, which
205
        // can be ignored.
206
        if( oldTab != null ) {
207
          if( newTab == null ) {
208
            closeRemainingTab();
209
          }
210
          else {
211
            // Update the preview with the edited text.
212
            refreshSelectedTab( (FileEditorTab)newTab );
213
          }
214
        }
215
      }
216
    );
217
  }
218
219
  private void initTextChangeListener( final FileEditorTab tab ) {
220
    tab.addTextChangeListener(
221
      (ObservableValue<? extends String> editor,
222
        final String oldValue, final String newValue) -> {
223
        refreshSelectedTab( tab );
224
      }
225
    );
226
  }
227
228
  private void initCaretParagraphListener( final FileEditorTab tab ) {
229
    tab.addCaretParagraphListener(
230
      (ObservableValue<? extends Integer> editor,
231
        final Integer oldValue, final Integer newValue) -> {
232
        refreshSelectedTab( tab );
233
      }
234
    );
235
  }
236
237
  private void initVariableNameInjector( final FileEditorTab tab ) {
238
    VariableNameInjector.listen( tab, getDefinitionPane() );
239
  }
240
241
  /**
242
   * Watch for changes to external files. In particular, this awaits
243
   * modifications to any XSL files associated with XML files being edited. When
244
   * an XSL file is modified (external to the application), the snitch's ears
245
   * perk up and the file is reloaded. This keeps the XSL transformation up to
246
   * date with what's on the file system.
247
   */
248
  private void initSnitch() {
249
    getSnitch().addObserver( this );
250
  }
251
252
  /**
253
   * Called whenever the preview pane becomes out of sync with the file editor
254
   * tab. This can be called when the text changes, the caret paragraph changes,
255
   * or the file tab changes.
256
   *
257
   * @param tab The file editor tab that has been changed in some fashion.
258
   */
259
  private void refreshSelectedTab( final FileEditorTab tab ) {
260
    if( tab.isFileOpen() ) {
261
      getPreviewPane().setPath( tab.getPath() );
262
263
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
264
      final Position p = tab.getCaretOffset();
265
      getLineNumberText().setText(
266
        get( STATUS_BAR_LINE,
267
          p.getMajor() + 1,
268
          p.getMinor() + 1,
269
          tab.getCaretPosition() + 1
270
        )
271
      );
272
273
      Processor<String> processor = getProcessors().get( tab );
274
275
      if( processor == null ) {
276
        processor = createProcessor( tab );
277
        getProcessors().put( tab, processor );
278
      }
279
280
      try {
281
        getNotifier().clear();
282
        processor.processChain( tab.getEditorText() );
283
      } catch( final Exception ex ) {
284
        error( ex );
285
      }
286
    }
287
  }
288
289
  /**
290
   * Used to find text in the active file editor window.
291
   */
292
  private void find() {
293
    final TextField input = new TextField();
294
295
    input.setOnKeyPressed( (KeyEvent event) -> {
296
      switch( event.getCode() ) {
297
        case F3:
298
        case ENTER:
299
          getActiveFileEditor().searchNext( input.getText() );
300
          break;
301
        case F:
302
          if( !event.isControlDown() ) {
303
            break;
304
          }
305
        case ESCAPE:
306
          getStatusBar().setGraphic( null );
307
          getActiveFileEditor().getEditorPane().requestFocus();
308
          break;
309
      }
310
    } );
311
312
    // Remove when the input field loses focus.
313
    input.focusedProperty().addListener(
314
      (
315
        final ObservableValue<? extends Boolean> focused,
316
        final Boolean oFocus,
317
        final Boolean nFocus) -> {
318
        if( !nFocus ) {
319
          getStatusBar().setGraphic( null );
320
        }
321
      }
322
    );
323
324
    getStatusBar().setGraphic( input );
325
326
    input.requestFocus();
327
  }
328
329
  public void findNext() {
330
    System.out.println( "find next" );
331
  }
332
333
  /**
334
   * Returns the variable map of interpolated definitions.
335
   *
336
   * @return A map to help dereference variables.
337
   */
338
  private Map<String, String> getResolvedMap() {
339
    return getDefinitionSource().getResolvedMap();
340
  }
341
342
  /**
343
   * Returns the root node for the hierarchical definition source.
344
   *
345
   * @return Data to display in the definition pane.
346
   */
347
  private TreeView<String> getTreeView() {
348
    try {
349
      return getDefinitionSource().asTreeView();
350
    } catch( Exception e ) {
351
      error( e );
352
    }
353
354
    // Slightly redundant as getDefinitionSource() might have returned an
355
    // empty definition source.
356
    return (new EmptyDefinitionSource()).asTreeView();
357
  }
358
359
  /**
360
   * Called when a definition source is opened.
361
   *
362
   * @param path Path to the definition source that was opened.
363
   */
364
  private void openDefinition( final Path path ) {
365
    try {
366
      final DefinitionSource ds = createDefinitionSource( path.toString() );
367
      setDefinitionSource( ds );
368
      storeDefinitionSource();
369
      updateDefinitionPane();
370
    } catch( final Exception e ) {
371
      error( e );
372
    }
373
  }
374
375
  private void updateDefinitionPane() {
376
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
377
  }
378
379
  private void restoreDefinitionSource() {
380
    final Preferences preferences = getPreferences();
381
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
382
383
    // If there's no definition source set, don't try to load it.
384
    if( source != null ) {
385
      setDefinitionSource( createDefinitionSource( source ) );
386
    }
387
  }
388
389
  private void storeDefinitionSource() {
390
    final Preferences preferences = getPreferences();
391
    final DefinitionSource ds = getDefinitionSource();
392
393
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
394
  }
395
396
  /**
397
   * Called when the last open tab is closed to clear the preview pane.
398
   */
399
  private void closeRemainingTab() {
400
    getPreviewPane().clear();
401
  }
402
403
  /**
404
   * Called when an exception occurs that warrants the user's attention.
405
   *
406
   * @param e The exception with a message that the user should know about.
407
   */
408
  private void error( final Exception e ) {
409
    getNotifier().notify( e );
410
  }
411
412
  //---- File actions -------------------------------------------------------
413
  /**
414
   * Called when an observable instance has changed. This is called by both the
415
   * snitch service and the notify service. The snitch service can be called for
416
   * different file types, including definition sources.
417
   *
418
   * @param observable The observed instance.
419
   * @param value The noteworthy item.
420
   */
421
  @Override
422
  public void update( final Observable observable, final Object value ) {
423
    if( value != null ) {
424
      if( observable instanceof Snitch && value instanceof Path ) {
425
        final Path path = (Path)value;
426
        final FileTypePredicate predicate
427
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
428
429
        // Reload definitions.
430
        if( predicate.test( path.toFile() ) ) {
431
          updateDefinitionSource( path );
432
        }
433
434
        updateSelectedTab();
435
      }
436
      else if( observable instanceof Notifier && value instanceof String ) {
437
        updateStatusBar( (String)value );
438
      }
439
    }
440
  }
441
442
  /**
443
   * Updates the status bar to show the given message.
444
   *
445
   * @param s The message to show in the status bar.
446
   */
447
  private void updateStatusBar( final String s ) {
448
    Platform.runLater(
449
      () -> {
450
        final int index = s.indexOf( '\n' );
451
        final String message = s.substring( 0, index > 0 ? index : s.length() );
452
453
        getStatusBar().setText( message );
454
      }
455
    );
456
  }
457
458
  /**
459
   * Called when a file has been modified.
460
   *
461
   * @param file Path to the modified file.
462
   */
463
  private void updateSelectedTab() {
464
    Platform.runLater(
465
      () -> {
466
        // Brute-force XSLT file reload by re-instantiating all processors.
467
        resetProcessors();
468
        refreshSelectedTab( getActiveFileEditor() );
469
      }
470
    );
471
  }
472
473
  /**
474
   * Reloads the definition source from the given path.
475
   *
476
   * @param path The path containing new definition information.
477
   */
478
  private void updateDefinitionSource( final Path path ) {
479
    Platform.runLater(
480
      () -> {
481
        openDefinition( path );
482
      }
483
    );
484
  }
485
486
  /**
487
   * After resetting the processors, they will refresh anew to be up-to-date
488
   * with the files (text and definition) currently loaded into the editor.
489
   */
490
  private void resetProcessors() {
491
    getProcessors().clear();
492
  }
493
494
  //---- File actions -------------------------------------------------------
495
  private void fileNew() {
496
    getFileEditorPane().newEditor();
497
  }
498
499
  private void fileOpen() {
500
    getFileEditorPane().openFileDialog();
501
  }
502
503
  private void fileClose() {
504
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
505
  }
506
507
  private void fileCloseAll() {
508
    getFileEditorPane().closeAllEditors();
509
  }
510
511
  private void fileSave() {
512
    getFileEditorPane().saveEditor( getActiveFileEditor() );
513
  }
514
515
  private void fileSaveAll() {
516
    getFileEditorPane().saveAllEditors();
517
  }
518
519
  private void fileExit() {
520
    final Window window = getWindow();
521
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
522
  }
523
524
  //---- Help actions -------------------------------------------------------
525
  private void helpAbout() {
526
    Alert alert = new Alert( AlertType.INFORMATION );
527
    alert.setTitle( get( "Dialog.about.title" ) );
528
    alert.setHeaderText( get( "Dialog.about.header" ) );
529
    alert.setContentText( get( "Dialog.about.content" ) );
530
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
531
    alert.initOwner( getWindow() );
532
533
    alert.showAndWait();
534
  }
535
536
  //---- Convenience accessors ----------------------------------------------
537
  private float getFloat( final String key, final float defaultValue ) {
538
    return getPreferences().getFloat( key, defaultValue );
539
  }
540
541
  private Preferences getPreferences() {
542
    return getOptions().getState();
543
  }
544
545
  protected Scene getScene() {
546
    if( this.scene == null ) {
547
      this.scene = createScene();
548
    }
549
550
    return this.scene;
551
  }
552
553
  public Window getWindow() {
554
    return getScene().getWindow();
555
  }
556
557
  private MarkdownEditorPane getActiveEditor() {
558
    final EditorPane pane = getActiveFileEditor().getEditorPane();
559
560
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
561
  }
562
563
  private FileEditorTab getActiveFileEditor() {
564
    return getFileEditorPane().getActiveFileEditor();
565
  }
566
567
  //---- Member accessors ---------------------------------------------------
568
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
569
    this.processors = map;
570
  }
571
572
  private Map<FileEditorTab, Processor<String>> getProcessors() {
573
    if( this.processors == null ) {
574
      setProcessors( new HashMap<>() );
575
    }
576
577
    return this.processors;
578
  }
579
580
  private FileEditorTabPane getFileEditorPane() {
581
    if( this.fileEditorPane == null ) {
582
      this.fileEditorPane = createFileEditorPane();
583
    }
584
585
    return this.fileEditorPane;
586
  }
587
588
  private HTMLPreviewPane getPreviewPane() {
589
    if( this.previewPane == null ) {
590
      this.previewPane = createPreviewPane();
591
    }
592
593
    return this.previewPane;
594
  }
595
596
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
597
    this.definitionSource = definitionSource;
598
  }
599
600
  private DefinitionSource getDefinitionSource() {
601
    if( this.definitionSource == null ) {
602
      this.definitionSource = new EmptyDefinitionSource();
603
    }
604
605
    return this.definitionSource;
606
  }
607
608
  private DefinitionPane getDefinitionPane() {
609
    if( this.definitionPane == null ) {
610
      this.definitionPane = createDefinitionPane();
611
    }
612
613
    return this.definitionPane;
614
  }
615
616
  private Options getOptions() {
617
    return this.options;
618
  }
619
620
  private Snitch getSnitch() {
621
    return this.snitch;
622
  }
623
624
  private Notifier getNotifier() {
625
    return this.notifier;
626
  }
627
628
  public void setMenuBar( final MenuBar menuBar ) {
629
    this.menuBar = menuBar;
630
  }
631
632
  public MenuBar getMenuBar() {
633
    return this.menuBar;
634
  }
635
636
  private Text getLineNumberText() {
637
    if( this.lineNumberText == null ) {
638
      this.lineNumberText = createLineNumberText();
639
    }
640
641
    return this.lineNumberText;
642
  }
643
644
  private synchronized StatusBar getStatusBar() {
645
    if( this.statusBar == null ) {
646
      this.statusBar = createStatusBar();
647
    }
648
649
    return this.statusBar;
650
  }
651
652
  //---- Member creators ----------------------------------------------------
653
  /**
654
   * Factory to create processors that are suited to different file types.
655
   *
656
   * @param tab The tab that is subjected to processing.
657
   *
658
   * @return A processor suited to the file type specified by the tab's path.
659
   */
660
  private Processor<String> createProcessor( final FileEditorTab tab ) {
661
    return createProcessorFactory().createProcessor( tab );
662
  }
663
664
  private ProcessorFactory createProcessorFactory() {
665
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
666
  }
667
668
  private DefinitionSource createDefinitionSource( final String path ) {
669
    final DefinitionSource ds
670
      = createDefinitionFactory().createDefinitionSource( path );
671
672
    if( ds instanceof FileDefinitionSource ) {
673
      try {
674
        getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
675
      } catch( final IOException ex ) {
676
        error( ex );
677
      }
678
    }
679
680
    return ds;
681
  }
682
683
  /**
684
   * Create an editor pane to hold file editor tabs.
685
   *
686
   * @return A new instance, never null.
687
   */
688
  private FileEditorTabPane createFileEditorPane() {
689
    return new FileEditorTabPane();
690
  }
691
692
  private HTMLPreviewPane createPreviewPane() {
693
    return new HTMLPreviewPane();
694
  }
695
696
  private DefinitionPane createDefinitionPane() {
697
    return new DefinitionPane( getTreeView() );
698
  }
699
700
  private DefinitionFactory createDefinitionFactory() {
701
    return new DefinitionFactory();
702
  }
703
704
  private StatusBar createStatusBar() {
705
    return new StatusBar();
706
  }
707
708
  private Scene createScene() {
709
    final SplitPane splitPane = new SplitPane(
710
      getDefinitionPane().getNode(),
711
      getFileEditorPane().getNode(),
712
      getPreviewPane().getNode() );
713
714
    splitPane.setDividerPositions(
715
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
716
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
717
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
718
719
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
720
    final BorderPane borderPane = new BorderPane();
721
    borderPane.setPrefSize( 1024, 800 );
722
    borderPane.setTop( createMenuBar() );
723
    borderPane.setBottom( getStatusBar() );
724
    borderPane.setCenter( splitPane );
725
726
    final VBox box = new VBox();
727
    box.setAlignment( Pos.BASELINE_CENTER );
728
    box.getChildren().add( getLineNumberText() );
729
    getStatusBar().getRightItems().add( box );
730
731
    return new Scene( borderPane );
732
  }
733
734
  private Text createLineNumberText() {
735
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
736
  }
737
738
  private Node createMenuBar() {
739
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
740
741
    // File actions
742
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
743
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
744
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
745
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
746
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
747
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
748
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
749
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
750
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
751
752
    // Edit actions
753
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
754
      e -> getActiveEditor().undo(),
755
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
756
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
757
      e -> getActiveEditor().redo(),
758
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
759
    Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
760
      e -> find(),
761
      activeFileEditorIsNull );
762
    Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
763
      e -> getActiveEditor().replace(),
764
      activeFileEditorIsNull );
765
    Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
766
      e -> findNext(),
672767
      activeFileEditorIsNull );
673768
    Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
M src/main/java/com/scrivenvar/definition/DefinitionFactory.java
5959
6060
  /**
61
   * 
62
   * @param path Path to a file containing definitions.
63
   * @return 
61
   * Creates a definition source capable of reading definitions from the given
62
   * path.
63
   *
64
   * @param path Path to a resource containing definitions.
65
   *
66
   * @return The definition source appropriate for the given path.
6467
   */
6568
  public DefinitionSource createDefinitionSource( final String path ) {
...
123126
      if( uri.isAbsolute() ) {
124127
        protocol = uri.getScheme();
125
      } else {
128
      }
129
      else {
126130
        final URL url = new URL( source );
127131
        protocol = url.getProtocol();
128132
      }
129133
    } catch( final URISyntaxException | MalformedURLException e ) {
130134
      // Could be HTTP, HTTPS?
131135
      if( source.startsWith( "//" ) ) {
132136
        throw new IllegalArgumentException( "Relative context: " + source );
133
      } else {
137
      }
138
      else {
134139
        final File file = new File( source );
135140
        protocol = getProtocol( file );
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
204204
   */
205205
  public VariableTreeItem<String> findLeaf( final String value ) {
206
    return findLeaf( value, false );
207
  }
208
209
  /**
210
   * Returns the leaf that matches the given value. If the value is terminally
211
   * punctuated, the punctuation is removed if no match was found.
212
   *
213
   * @param value The value to find, never null.
214
   * @param contains Set to true to perform a substring match if starts with
215
   * fails to match.
216
   *
217
   * @return The leaf that contains the given value, or null if neither the
218
   * original value nor the terminally-trimmed value was found.
219
   */
220
  public VariableTreeItem<String> findLeaf(
221
    final String value,
222
    final boolean contains ) {
223
206224
    final VariableTreeItem<String> root = getTreeRoot();
207
    final VariableTreeItem<String> leaf = root.findLeaf( value );
225
    final VariableTreeItem<String> leaf = root.findLeaf( value, contains );
208226
209227
    return leaf == null
M src/main/java/com/scrivenvar/definition/FileDefinitionSource.java
5454
  }
5555
56
  protected Path getPath() {
56
  public Path getPath() {
5757
    return this.path;
5858
  }
M src/main/java/com/scrivenvar/definition/VariableTreeItem.java
4646
4747
  private final static int DEFAULT_MAP_SIZE = 1000;
48
  
49
  private final static VariableDecorator VARIABLE_DECORATOR =
50
    new YamlVariableDecorator();
48
49
  private final static VariableDecorator VARIABLE_DECORATOR
50
    = new YamlVariableDecorator();
5151
5252
  /**
...
7373
   */
7474
  public VariableTreeItem<T> findLeaf( final String text ) {
75
    return findLeaf( text, false );
76
  }
77
78
  /**
79
   * Finds a leaf starting at the current node with text that matches the given
80
   * value.
81
   *
82
   * @param text The text to match against each leaf in the tree.
83
   * @param contains Set to true to perform a substring match if starts with
84
   * fails.
85
   *
86
   * @return The leaf that has a value starting with the given text.
87
   */
88
  public VariableTreeItem<T> findLeaf(
89
    final String text,
90
    final boolean contains ) {
91
7592
    final Stack<VariableTreeItem<T>> stack = new Stack<>();
7693
    final VariableTreeItem<T> root = this;
...
84101
      node = stack.pop();
85102
86
      if( node.valueStartsWith( text ) ) {
103
      if( contains && node.valueContains( text ) ) {
87104
        found = true;
88
      } else {
105
      }
106
      else if( !contains && node.valueStartsWith( text ) ) {
107
        found = true;
108
      }
109
      else {
89110
        for( final TreeItem<T> child : node.getChildren() ) {
90111
          stack.push( (VariableTreeItem<T>)child );
...
109130
  private boolean valueStartsWith( final String s ) {
110131
    return isLeaf() && getValue().toString().startsWith( s );
132
  }
133
134
  /**
135
   * Returns true if this node is a leaf and its value contains the given text.
136
   *
137
   * @param s The text to compare against the node value.
138
   *
139
   * @return true Node is a leaf and its value contains the given value.
140
   */
141
  private boolean valueContains( final String s ) {
142
    return isLeaf() && getValue().toString().contains( s );
111143
  }
112144
...
172204
173205
        map.put( key, value );
174
      } else {
206
      }
207
      else {
175208
        populate( child, map );
176209
      }
M src/main/java/com/scrivenvar/editors/EditorPane.java
4343
import org.fxmisc.wellbehaved.event.EventPattern;
4444
import org.fxmisc.wellbehaved.event.InputMap;
45
import org.fxmisc.wellbehaved.event.Nodes;
4645
import static org.fxmisc.wellbehaved.event.InputMap.consume;
46
import org.fxmisc.wellbehaved.event.Nodes;
4747
4848
/**
...
7373
  public void redo() {
7474
    getUndoManager().redo();
75
  }
76
  
77
  public void find() {
78
    System.out.println( "search" );
7975
  }
8076
  
8177
  public void replace() {
8278
    System.out.println( "replace" );
8379
  }
8480
  
85
  public void findNext() {
86
    System.out.println( "find next" );
87
  }
88
8981
  public void findPrevious() {
9082
    System.out.println( "find previous" );
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
265265
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
266266
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 );
267
    VariableTreeItem<String> leaf = findLeaf( word );
268
269
    if( leaf == null ) {
270
      // If a leaf doesn't match using "starts with", then try using "contains".
271
      leaf = findLeaf( word, true );
272
    }
273
274
    if( leaf != null ) {
275
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
276
      decorateVariable();
277
      expand( leaf );
278
    }
279
  }
280
281
  /**
282
   * Called when autocomplete finishes on a valid leaf or when the user presses
283
   * Enter to finish manual autocomplete.
284
   */
285
  private void decorateVariable() {
286
    // A little bit of duplication...
287
    final String paragraph = getCaretParagraph();
288
    final int[] boundaries = getWordBoundaries( paragraph );
289
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
290
291
    final String newVariable = getVariableDecorator().decorate( old );
292
293
    final int posEnded = getCurrentCaretPosition();
294
    final int posBegan = posEnded - old.length();
295
296
    getEditor().replaceText( posBegan, posEnded, newVariable );
297
  }
298
299
  /**
300
   * Updates the text at the given position within the current paragraph.
301
   *
302
   * @param posBegan The starting index in the paragraph text to replace.
303
   * @param posEnded The ending index in the paragraph text to replace.
304
   * @param text Overwrite the paragraph substring with this text.
305
   */
306
  private void replaceText(
307
    final int posBegan, final int posEnded, final String text ) {
308
    final int p = getCurrentParagraph();
309
310
    getEditor().replaceText( p, posBegan, p, posEnded, text );
311
  }
312
313
  /**
314
   * Returns the caret's current paragraph position.
315
   *
316
   * @return A number greater than or equal to 0.
317
   */
318
  private int getCurrentParagraph() {
319
    return getEditor().getCurrentParagraph();
320
  }
321
322
  /**
323
   * Returns current word boundary indexes into the current paragraph, including
324
   * punctuation.
325
   *
326
   * @param p The paragraph wherein to hunt word boundaries.
327
   * @param offset The offset into the paragraph to begin scanning left and
328
   * right.
329
   *
330
   * @return The starting and ending index of the word closest to the caret.
331
   */
332
  private int[] getWordBoundaries( final String p, final int offset ) {
333
    // Remove dashes, but retain hyphens. Retain same number of characters
334
    // to preserve relative indexes.
335
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
336
337
    return getWordAt( paragraph, offset );
338
  }
339
340
  /**
341
   * Helper method to get the word boundaries for the current paragraph.
342
   *
343
   * @param paragraph
344
   *
345
   * @return
346
   */
347
  private int[] getWordBoundaries( final String paragraph ) {
348
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
349
  }
350
351
  /**
352
   * Given an arbitrary offset into a string, this returns the word at that
353
   * index. The inputs and outputs include:
354
   *
355
   * <ul>
356
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
357
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
358
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
359
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
360
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
361
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
362
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
363
   * </ul>
364
   *
365
   * @param p The string to scan for a word.
366
   * @param offset The offset within s to begin searching for the nearest word
367
   * boundary, must not be out of bounds of s.
368
   *
369
   * @return The word in s at the offset.
370
   *
371
   * @see getWordBegan( String, int )
372
   * @see getWordEnded( String, int )
373
   */
374
  private int[] getWordAt( final String p, final int offset ) {
375
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
376
  }
377
378
  /**
379
   * Returns the index into s where a word begins.
380
   *
381
   * @param s Never null.
382
   * @param offset Index into s to begin searching backwards for a word
383
   * boundary.
384
   *
385
   * @return The index where a word begins.
386
   */
387
  private int getWordBegan( final String s, int offset ) {
388
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
389
      offset--;
390
    }
391
392
    return offset;
393
  }
394
395
  /**
396
   * Returns the index into s where a word ends.
397
   *
398
   * @param s Never null.
399
   * @param offset Index into s to begin searching forwards for a word boundary.
400
   *
401
   * @return The index where a word ends.
402
   */
403
  private int getWordEnded( final String s, int offset ) {
404
    final int length = s.length();
405
406
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
407
      offset++;
408
    }
409
410
    return offset;
411
  }
412
413
  /**
414
   * Returns true if the given character can be reasonably expected to be part
415
   * of a word, including punctuation marks.
416
   *
417
   * @param c The character to compare.
418
   *
419
   * @return false The character is a space character.
420
   */
421
  private boolean isBoundary( final char c ) {
422
    return !isSpaceChar( c );
423
  }
424
425
  /**
426
   * Returns the text for the paragraph that contains the caret.
427
   *
428
   * @return A non-null string, possibly empty.
429
   */
430
  private String getCaretParagraph() {
431
    return getEditor().getText( getCurrentParagraph() );
432
  }
433
434
  /**
435
   * Returns true if the node has children that can be selected (i.e., any
436
   * non-leaves).
437
   *
438
   * @param <T> The type that the TreeItem contains.
439
   * @param node The node to test for terminality.
440
   *
441
   * @return true The node has one branch and its a leaf.
442
   */
443
  private <T> boolean isTerminal( final TreeItem<T> node ) {
444
    final ObservableList<TreeItem<T>> branches = node.getChildren();
445
446
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
447
  }
448
449
  /**
450
   * Inserts text that the user typed at the current caret position, then
451
   * performs an autocomplete for the variable name.
452
   *
453
   * @param text The text to insert, never null.
454
   */
455
  private void typed( final String text ) {
456
    getEditor().replaceSelection( text );
457
    vModeAutocomplete();
458
  }
459
460
  /**
461
   * Called when the user presses either End or Enter key.
462
   */
463
  private void acceptPath() {
464
    final IndexRange range = getSelectionRange();
465
466
    if( range != null ) {
467
      final int rangeEnd = range.getEnd();
468
      final StyledTextArea textArea = getEditor();
469
      textArea.deselect();
470
      textArea.moveTo( rangeEnd );
471
    }
472
  }
473
474
  /**
475
   * Replaces the entirety of the existing path (from the initial caret
476
   * position) with the given path.
477
   *
478
   * @param oldPath The path to replace.
479
   * @param newPath The replacement path.
480
   */
481
  private void replacePath( final String oldPath, final String newPath ) {
482
    final StyledTextArea textArea = getEditor();
483
    final int posBegan = getInitialCaretPosition();
484
    final int posEnded = posBegan + oldPath.length();
485
486
    textArea.deselect();
487
    textArea.replaceText( posBegan, posEnded, newPath );
488
  }
489
490
  /**
491
   * Called when the user presses the Backspace key.
492
   */
493
  private void deleteSelection() {
494
    final StyledTextArea textArea = getEditor();
495
    textArea.replaceSelection( "" );
496
    textArea.deletePreviousChar();
497
  }
498
499
  /**
500
   * Cycles the selected text through the nodes.
501
   *
502
   * @param direction true - next; false - previous
503
   */
504
  private void cycleSelection( final boolean direction ) {
505
    final TreeItem<String> node = getCurrentNode();
506
507
    // Find the sibling for the current selection and replace the current
508
    // selection with the sibling's value
509
    TreeItem< String> cycled = direction
510
      ? node.nextSibling()
511
      : node.previousSibling();
512
513
    // When cycling at the end (or beginning) of the list, jump to the first
514
    // (or last) sibling depending on the cycle direction.
515
    if( cycled == null ) {
516
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
517
    }
518
519
    final String path = getCurrentPath();
520
    final String cycledWord = cycled.getValue();
521
    final String word = getLastPathWord();
522
    final int index = path.indexOf( word );
523
    final String cycledPath = path.substring( 0, index ) + cycledWord;
524
525
    expand( cycled );
526
    replacePath( path, cycledPath );
527
  }
528
529
  /**
530
   * Cycles to the next sibling of the currently selected tree node.
531
   */
532
  private void cyclePathNext() {
533
    cycleSelection( true );
534
  }
535
536
  /**
537
   * Cycles to the previous sibling of the currently selected tree node.
538
   */
539
  private void cyclePathPrev() {
540
    cycleSelection( false );
541
  }
542
543
  /**
544
   * Returns the variable name (or as much as has been typed so far). Returns
545
   * all the characters from the initial caret column to the the first
546
   * whitespace character. This will return a path that contains zero or more
547
   * separators.
548
   *
549
   * @return A non-null string, possibly empty.
550
   */
551
  private String getCurrentPath() {
552
    final String s = extractTextChunk();
553
    final int length = s.length();
554
555
    int i = 0;
556
557
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
558
      i++;
559
    }
560
561
    return s.substring( 0, i );
562
  }
563
564
  private <T> ObservableList<TreeItem<T>> getSiblings(
565
    final TreeItem<T> item ) {
566
    final TreeItem<T> parent = item.getParent();
567
    return parent == null ? item.getChildren() : parent.getChildren();
568
  }
569
570
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
571
    return getFirst( getSiblings( item ), item );
572
  }
573
574
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
575
    return getLast( getSiblings( item ), item );
576
  }
577
578
  /**
579
   * Returns the caret position as an offset into the text.
580
   *
581
   * @return A value from 0 to the length of the text (minus one).
582
   */
583
  private int getCurrentCaretPosition() {
584
    return getEditor().getCaretPosition();
585
  }
586
587
  /**
588
   * Returns the caret position within the current paragraph.
589
   *
590
   * @return A value from 0 to the length of the current paragraph.
591
   */
592
  private int getCurrentCaretColumn() {
593
    return getEditor().getCaretColumn();
594
  }
595
596
  /**
597
   * Returns the last word from the path.
598
   *
599
   * @return The last token.
600
   */
601
  private String getLastPathWord() {
602
    String path = getCurrentPath();
603
604
    int i = path.indexOf( SEPARATOR_CHAR );
605
606
    while( i > 0 ) {
607
      path = path.substring( i + 1 );
608
      i = path.indexOf( SEPARATOR_CHAR );
609
    }
610
611
    return path;
612
  }
613
614
  /**
615
   * Returns text from the initial caret position until some arbitrarily long
616
   * number of characters. The number of characters extracted will be
617
   * getMaxVarLength, or fewer, depending on how many characters remain to be
618
   * extracted. The result from this method is trimmed to the first whitespace
619
   * character.
620
   *
621
   * @return A chunk of text that includes all the words representing a path,
622
   * and then some.
623
   */
624
  private String extractTextChunk() {
625
    final StyledTextArea textArea = getEditor();
626
    final int textBegan = getInitialCaretPosition();
627
    final int remaining = textArea.getLength() - textBegan;
628
    final int textEnded = min( remaining, getMaxVarLength() );
629
630
    try {
631
      return textArea.getText( textBegan, textEnded );
632
    } catch( final Exception e ) {
633
      return textArea.getText();
634
    }
635
  }
636
637
  /**
638
   * Returns the node for the current path.
639
   */
640
  private TreeItem<String> getCurrentNode() {
641
    return findNode( getCurrentPath() );
642
  }
643
644
  /**
645
   * Finds the node that most closely matches the given path.
646
   *
647
   * @param path The path that represents a node.
648
   *
649
   * @return The node for the path, or the root node if the path could not be
650
   * found, but never null.
651
   */
652
  private TreeItem<String> findNode( final String path ) {
653
    return getDefinitionPane().findNode( path );
654
  }
655
656
  /**
657
   * Finds the first leaf having a value that starts with the given text.
658
   *
659
   * @param text The text to find in the definition tree.
660
   *
661
   * @return The leaf that starts with the given text, or null if not found.
662
   */
663
  private VariableTreeItem<String> findLeaf( final String text ) {
664
    return getDefinitionPane().findLeaf( text, false );
665
  }
666
667
  /**
668
   * Finds the first leaf having a value that starts with the given text, or
669
   * contains the text if contains is true.
670
   *
671
   * @param text The text to find in the definition tree.
672
   * @param contains Set true to perform a substring match after a starts with
673
   * match.
674
   *
675
   * @return The leaf that starts with the given text, or null if not found.
676
   */
677
  private VariableTreeItem<String> findLeaf(
678
    final String text,
679
    final boolean contains ) {
680
    return getDefinitionPane().findLeaf( text, contains );
660681
  }
661682
M src/main/java/com/scrivenvar/service/Snitch.java
5656
   * or events.
5757
   *
58
   * @param file Send notifications when this file changes.
58
   * @param file Send notifications when this file changes, can be null.
5959
   *
6060
   * @throws IOException Couldn't create a watcher for the given file.
6161
   */
6262
  public void listen( Path file ) throws IOException;
6363
6464
  /**
6565
   * Removes the given file from the notifications list.
6666
   *
67
   * @param file The file to stop monitoring for any changes.
67
   * @param file The file to stop monitoring for any changes, can be null.
6868
   */
69
  public void ignore( final Path file );
69
  public void ignore( Path file );
7070
7171
  /**
M src/main/java/com/scrivenvar/service/events/impl/DefaultNotifier.java
7171
   * @param args Parameters for the message content.
7272
   *
73
   * @return
73
   * @return A notification instance, never null.
7474
   */
7575
  @Override
M src/main/java/com/scrivenvar/service/impl/DefaultSnitch.java
9191
  @Override
9292
  public void listen( final Path file ) throws IOException {
93
    if( getEavesdropped().add( file ) ) {
93
    if( file != null && getEavesdropped().add( file ) ) {
9494
      final Path dir = toDirectory( file );
9595
      final WatchKey key = dir.register( getWatchService(), ENTRY_MODIFY );
...
124124
  @Override
125125
  public void ignore( final Path file ) {
126
    final Path directory = toDirectory( file );
126
    if( file != null ) {
127
      final Path directory = toDirectory( file );
127128
128
    // Remove all occurrences (there should be only one).
129
    getWatchMap().values().removeAll( Collections.singleton( directory ) );
129
      // Remove all occurrences (there should be only one).
130
      getWatchMap().values().removeAll( Collections.singleton( directory ) );
130131
131
    // Remove all occurrences (there can be only one).
132
    getEavesdropped().remove( file );
132
      // Remove all occurrences (there can be only one).
133
      getEavesdropped().remove( file );
134
    }
133135
  }
134136