Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
A _config.yaml
1
application:
2
  title: Scrivenvar
3
14
M build.gradle
1
version = '1.0.8'
1
version = '1.0.9'
22
33
apply plugin: 'java'
D scrivenvar.yaml
1
application:
2
  title: Scrivenvar
3
41
M src/main/java/com/scrivenvar/Constants.java
6161
  public static final String STYLESHEET_MARKDOWN = get( "file.stylesheet.markdown" );
6262
  public static final String STYLESHEET_PREVIEW = get( "file.stylesheet.preview" );
63
  public static final String STYLESHEET_XML = get( "file.stylesheet.xml" );
6364
6465
  public static final String FILE_LOGO_16 = get( "file.logo.16" );
M src/main/java/com/scrivenvar/MainWindow.java
6161
import javafx.collections.ObservableList;
6262
import static javafx.event.Event.fireEvent;
63
import javafx.scene.Node;
64
import javafx.scene.Scene;
65
import javafx.scene.control.Alert;
66
import javafx.scene.control.Alert.AlertType;
67
import javafx.scene.control.Menu;
68
import javafx.scene.control.MenuBar;
69
import javafx.scene.control.SplitPane;
70
import javafx.scene.control.Tab;
71
import javafx.scene.control.ToolBar;
72
import javafx.scene.control.TreeView;
73
import javafx.scene.image.Image;
74
import javafx.scene.image.ImageView;
75
import static javafx.scene.input.KeyCode.ESCAPE;
76
import javafx.scene.input.KeyEvent;
77
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
78
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
79
import javafx.scene.layout.BorderPane;
80
import javafx.scene.layout.VBox;
81
import javafx.stage.Window;
82
import javafx.stage.WindowEvent;
83
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
84
import org.controlsfx.control.StatusBar;
85
86
/**
87
 * Main window containing a tab pane in the center for file editors.
88
 *
89
 * @author Karl Tauber and White Magic Software, Ltd.
90
 */
91
public class MainWindow implements Observer {
92
93
  private final Options options = Services.load( Options.class );
94
  private final Snitch snitch = Services.load( Snitch.class );
95
  private final Notifier notifier = Services.load( Notifier.class );
96
97
  private Scene scene;
98
  private MenuBar menuBar;
99
  private StatusBar statusBar;
100
101
  private DefinitionSource definitionSource;
102
  private DefinitionPane definitionPane;
103
  private FileEditorTabPane fileEditorPane;
104
  private HTMLPreviewPane previewPane;
105
106
  /**
107
   * Prevent re-instantiation processing classes.
108
   */
109
  private Map<FileEditorTab, Processor<String>> processors;
110
111
  public MainWindow() {
112
    initLayout();
113
    initDefinitionListener();
114
    initTabAddedListener();
115
    initTabChangedListener();
116
    initPreferences();
117
    initSnitch();
118
  }
119
120
  /**
121
   * Listen for file editor tab pane to receive an open definition source event.
122
   */
123
  private void initDefinitionListener() {
124
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
125
      (ObservableValue<? extends Path> definitionFile,
126
        final Path oldPath, final Path newPath) -> {
127
        openDefinition( newPath );
128
129
        // Indirectly refresh the resolved map.
130
        setProcessors( null );
131
132
        // Will create new processors and therefore a new resolved map.
133
        refreshSelectedTab( getActiveFileEditor() );
134
135
        updateDefinitionPane();
136
      }
137
    );
138
  }
139
140
  /**
141
   * When tabs are added, hook the various change listeners onto the new tab so
142
   * that the preview pane refreshes as necessary.
143
   */
144
  private void initTabAddedListener() {
145
    final FileEditorTabPane editorPane = getFileEditorPane();
146
147
    // Make sure the text processor kicks off when new files are opened.
148
    final ObservableList<Tab> tabs = editorPane.getTabs();
149
150
    // Update the preview pane on tab changes.
151
    tabs.addListener(
152
      (final Change<? extends Tab> change) -> {
153
        while( change.next() ) {
154
          if( change.wasAdded() ) {
155
            // Multiple tabs can be added simultaneously.
156
            for( final Tab newTab : change.getAddedSubList() ) {
157
              final FileEditorTab tab = (FileEditorTab)newTab;
158
159
              initTextChangeListener( tab );
160
              initCaretParagraphListener( tab );
161
              initVariableNameInjector( tab );
162
            }
163
          }
164
        }
165
      }
166
    );
167
  }
168
169
  /**
170
   * Reloads the preferences from the previous load.
171
   */
172
  private void initPreferences() {
173
    restoreDefinitionSource();
174
    getFileEditorPane().restorePreferences();
175
    updateDefinitionPane();
176
  }
177
178
  /**
179
   * Listen for new tab selection events.
180
   */
181
  private void initTabChangedListener() {
182
    final FileEditorTabPane editorPane = getFileEditorPane();
183
184
    // Update the preview pane changing tabs.
185
    editorPane.addTabSelectionListener(
186
      (ObservableValue<? extends Tab> tabPane,
187
        final Tab oldTab, final Tab newTab) -> {
188
189
        // If there was no old tab, then this is a first time load, which
190
        // can be ignored.
191
        if( oldTab != null ) {
192
          if( newTab == null ) {
193
            closeRemainingTab();
194
          } else {
195
            // Update the preview with the edited text.
196
            refreshSelectedTab( (FileEditorTab)newTab );
197
          }
198
        }
199
      }
200
    );
201
  }
202
203
  private void initTextChangeListener( final FileEditorTab tab ) {
204
    tab.addTextChangeListener(
205
      (ObservableValue<? extends String> editor,
206
        final String oldValue, final String newValue) -> {
207
        refreshSelectedTab( tab );
208
      }
209
    );
210
  }
211
212
  private void initCaretParagraphListener( final FileEditorTab tab ) {
213
    tab.addCaretParagraphListener(
214
      (ObservableValue<? extends Integer> editor,
215
        final Integer oldValue, final Integer newValue) -> {
216
        refreshSelectedTab( tab );
217
      }
218
    );
219
  }
220
221
  private void initVariableNameInjector( final FileEditorTab tab ) {
222
    VariableNameInjector.listen( tab, getDefinitionPane() );
223
  }
224
225
  /**
226
   * Watch for changes to external files. In particular, this awaits
227
   * modifications to any XSL files associated with XML files being edited. When
228
   * an XSL file is modified (external to the application), the snitch's ears
229
   * perk up and the file is reloaded. This keeps the XSL transformation up to
230
   * date with what's on the file system.
231
   */
232
  private void initSnitch() {
233
    getSnitch().addObserver( this );
234
  }
235
236
  /**
237
   * Called whenever the preview pane becomes out of sync with the file editor
238
   * tab. This can be called when the text changes, the caret paragraph changes,
239
   * or the file tab changes.
240
   *
241
   * @param tab The file editor tab that has been changed in some fashion.
242
   */
243
  private void refreshSelectedTab( final FileEditorTab tab ) {
244
    if( tab.isFileOpen() ) {
245
      getPreviewPane().setPath( tab.getPath() );
246
247
      Processor<String> processor = getProcessors().get( tab );
248
249
      if( processor == null ) {
250
        processor = createProcessor( tab );
251
        getProcessors().put( tab, processor );
252
      }
253
254
      try {
255
        processor.processChain( tab.getEditorText() );
256
        getNotifier().clear();
257
      } catch( final Exception ex ) {
258
        error( ex );
259
      }
260
    }
261
  }
262
263
  /**
264
   * Returns the variable map of interpolated definitions.
265
   *
266
   * @return A map to help dereference variables.
267
   */
268
  private Map<String, String> getResolvedMap() {
269
    return getDefinitionSource().getResolvedMap();
270
  }
271
272
  /**
273
   * Returns the root node for the hierarchical definition source.
274
   *
275
   * @return Data to display in the definition pane.
276
   */
277
  private TreeView<String> getTreeView() {
278
    try {
279
      return getDefinitionSource().asTreeView();
280
    } catch( Exception e ) {
281
      error( e );
282
    }
283
284
    return new TreeView<>();
285
  }
286
287
  /**
288
   * Called when a definition source is opened.
289
   *
290
   * @param path Path to the definition source that was opened.
291
   */
292
  private void openDefinition( final Path path ) {
293
    try {
294
      final DefinitionSource ds = createDefinitionSource( path.toString() );
295
      setDefinitionSource( ds );
296
      storeDefinitionSource();
297
      updateDefinitionPane();
298
    } catch( final Exception e ) {
299
      error( e );
300
    }
301
  }
302
303
  private void updateDefinitionPane() {
304
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
305
  }
306
307
  private void restoreDefinitionSource() {
308
    final Preferences preferences = getPreferences();
309
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
310
311
    // If there's no definition source set, don't try to load it.
312
    if( source != null ) {
313
      setDefinitionSource( createDefinitionSource( source ) );
314
    }
315
  }
316
317
  private void storeDefinitionSource() {
318
    final Preferences preferences = getPreferences();
319
    final DefinitionSource ds = getDefinitionSource();
320
321
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
322
  }
323
324
  /**
325
   * Called when the last open tab is closed to clear the preview pane.
326
   */
327
  private void closeRemainingTab() {
328
    getPreviewPane().clear();
329
  }
330
331
  /**
332
   * Called when an exception occurs that warrants the user's attention.
333
   *
334
   * @param e The exception with a message that the user should know about.
335
   */
336
  private void error( final Exception e ) {
337
    getNotifier().notify( e );
338
  }
339
340
  //---- File actions -------------------------------------------------------
341
  /**
342
   * Called when an observable instance has changed. This includes the snitch
343
   * service and the notify service.
344
   *
345
   * @param observable The observed instance.
346
   * @param o The noteworthy item.
347
   */
348
  @Override
349
  public void update( final Observable observable, final Object o ) {
350
    if( observable instanceof Snitch ) {
351
      if( o instanceof Path ) {
352
        update( (Path)o );
353
      }
354
    } else if( observable instanceof Notifier && o != null ) {
355
      final String s = (String)o;
356
      final int index = s.indexOf( '\n' );
357
      final String message = s.substring( 0, index > 0 ? index : s.length() );
358
359
      getStatusBar().setText( message );
360
    }
361
  }
362
363
  /**
364
   * Called when a file has been modified.
365
   *
366
   * @param file Path to the modified file.
367
   */
368
  private void update( final Path file ) {
369
    // Avoid throwing IllegalStateException by running from a non-JavaFX thread.
370
    Platform.runLater(
371
      () -> {
372
        // Brute-force XSLT file reload by re-instantiating all processors.
373
        resetProcessors();
374
        refreshSelectedTab( getActiveFileEditor() );
375
      }
376
    );
377
  }
378
379
  /**
380
   * After resetting the processors, they will refresh anew to be up-to-date
381
   * with the files (text and definition) currently loaded into the editor.
382
   */
383
  private void resetProcessors() {
384
    getProcessors().clear();
385
  }
386
387
  //---- File actions -------------------------------------------------------
388
  private void fileNew() {
389
    getFileEditorPane().newEditor();
390
  }
391
392
  private void fileOpen() {
393
    getFileEditorPane().openFileDialog();
394
  }
395
396
  private void fileClose() {
397
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
398
  }
399
400
  private void fileCloseAll() {
401
    getFileEditorPane().closeAllEditors();
402
  }
403
404
  private void fileSave() {
405
    getFileEditorPane().saveEditor( getActiveFileEditor() );
406
  }
407
408
  private void fileSaveAll() {
409
    getFileEditorPane().saveAllEditors();
410
  }
411
412
  private void fileExit() {
413
    final Window window = getWindow();
414
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
415
  }
416
417
  //---- Help actions -------------------------------------------------------
418
  private void helpAbout() {
419
    Alert alert = new Alert( AlertType.INFORMATION );
420
    alert.setTitle( get( "Dialog.about.title" ) );
421
    alert.setHeaderText( get( "Dialog.about.header" ) );
422
    alert.setContentText( get( "Dialog.about.content" ) );
423
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
424
    alert.initOwner( getWindow() );
425
426
    alert.showAndWait();
427
  }
428
429
  //---- Convenience accessors ----------------------------------------------
430
  private float getFloat( final String key, final float defaultValue ) {
431
    return getPreferences().getFloat( key, defaultValue );
432
  }
433
434
  private Preferences getPreferences() {
435
    return getOptions().getState();
436
  }
437
438
  public Window getWindow() {
439
    return getScene().getWindow();
440
  }
441
442
  private MarkdownEditorPane getActiveEditor() {
443
    final EditorPane pane = getActiveFileEditor().getEditorPane();
444
445
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
446
  }
447
448
  private FileEditorTab getActiveFileEditor() {
449
    return getFileEditorPane().getActiveFileEditor();
450
  }
451
452
  //---- Member accessors ---------------------------------------------------
453
  private void setScene( Scene scene ) {
454
    this.scene = scene;
455
  }
456
457
  public Scene getScene() {
458
    return this.scene;
459
  }
460
461
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
462
    this.processors = map;
463
  }
464
465
  private Map<FileEditorTab, Processor<String>> getProcessors() {
466
    if( this.processors == null ) {
467
      setProcessors( new HashMap<>() );
468
    }
469
470
    return this.processors;
471
  }
472
473
  private FileEditorTabPane getFileEditorPane() {
474
    if( this.fileEditorPane == null ) {
475
      this.fileEditorPane = createFileEditorPane();
476
    }
477
478
    return this.fileEditorPane;
479
  }
480
481
  private HTMLPreviewPane getPreviewPane() {
482
    if( this.previewPane == null ) {
483
      this.previewPane = createPreviewPane();
484
    }
485
486
    return this.previewPane;
487
  }
488
489
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
490
    this.definitionSource = definitionSource;
491
  }
492
493
  private DefinitionSource getDefinitionSource() {
494
    if( this.definitionSource == null ) {
495
      this.definitionSource = new EmptyDefinitionSource();
496
    }
497
498
    return this.definitionSource;
499
  }
500
501
  private DefinitionPane getDefinitionPane() {
502
    if( this.definitionPane == null ) {
503
      this.definitionPane = createDefinitionPane();
504
    }
505
506
    return this.definitionPane;
507
  }
508
509
  private Options getOptions() {
510
    return this.options;
511
  }
512
513
  private Snitch getSnitch() {
514
    return this.snitch;
515
  }
516
517
  private Notifier getNotifier() {
518
    return this.notifier;
519
  }
520
521
  public void setMenuBar( final MenuBar menuBar ) {
522
    this.menuBar = menuBar;
523
  }
524
525
  public MenuBar getMenuBar() {
526
    return this.menuBar;
527
  }
528
529
  private synchronized StatusBar getStatusBar() {
530
    if( this.statusBar == null ) {
531
      this.statusBar = createStatusBar();
532
    }
533
534
    return this.statusBar;
535
  }
536
537
  //---- Member creators ----------------------------------------------------
538
  /**
539
   * Factory to create processors that are suited to different file types.
540
   *
541
   * @param tab The tab that is subjected to processing.
542
   *
543
   * @return A processor suited to the file type specified by the tab's path.
544
   */
545
  private Processor<String> createProcessor( final FileEditorTab tab ) {
546
    return createProcessorFactory().createProcessor( tab );
547
  }
548
549
  private ProcessorFactory createProcessorFactory() {
550
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
551
  }
552
553
  private DefinitionSource createDefinitionSource( final String path ) {
554
    return createDefinitionFactory().createDefinitionSource( path );
555
  }
556
557
  /**
558
   * Create an editor pane to hold file editor tabs.
559
   *
560
   * @return A new instance, never null.
561
   */
562
  private FileEditorTabPane createFileEditorPane() {
563
    return new FileEditorTabPane();
564
  }
565
566
  private HTMLPreviewPane createPreviewPane() {
567
    return new HTMLPreviewPane();
568
  }
569
570
  private DefinitionPane createDefinitionPane() {
571
    return new DefinitionPane( getTreeView() );
572
  }
573
574
  private DefinitionFactory createDefinitionFactory() {
575
    return new DefinitionFactory();
576
  }
577
578
  private StatusBar createStatusBar() {
579
    return new StatusBar();
580
  }
581
582
  private Node createMenuBar() {
583
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
584
585
    // File actions
586
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
587
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
588
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
589
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
590
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
591
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
592
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
593
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
594
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
595
596
    // Edit actions
597
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
598
      e -> getActiveEditor().undo(),
599
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
600
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
601
      e -> getActiveEditor().redo(),
602
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
603
    Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Shortcut+F", SEARCH,
604
      e -> getActiveEditor().find(),
605
      activeFileEditorIsNull );
606
    Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
607
      e -> getActiveEditor().replace(),
608
      activeFileEditorIsNull );
609
    Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
610
      e -> getActiveEditor().findNext(),
611
      activeFileEditorIsNull );
612
    Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
613
      e -> getActiveEditor().findPrevious(),
614
      activeFileEditorIsNull );
615
616
    // Insert actions
617
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
618
      e -> getActiveEditor().surroundSelection( "**", "**" ),
619
      activeFileEditorIsNull );
620
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
621
      e -> getActiveEditor().surroundSelection( "*", "*" ),
622
      activeFileEditorIsNull );
623
    Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
624
      e -> getActiveEditor().surroundSelection( "^", "^" ),
625
      activeFileEditorIsNull );
626
    Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
627
      e -> getActiveEditor().surroundSelection( "~", "~" ),
628
      activeFileEditorIsNull );
629
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
630
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
631
      activeFileEditorIsNull );
632
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
633
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
634
      activeFileEditorIsNull );
635
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
636
      e -> getActiveEditor().surroundSelection( "`", "`" ),
637
      activeFileEditorIsNull );
638
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
639
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
640
      activeFileEditorIsNull );
641
642
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
643
      e -> getActiveEditor().insertLink(),
644
      activeFileEditorIsNull );
645
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
646
      e -> getActiveEditor().insertImage(),
647
      activeFileEditorIsNull );
648
649
    final Action[] headers = new Action[ 6 ];
650
651
    // Insert header actions (H1 ... H6)
652
    for( int i = 1; i <= 6; i++ ) {
653
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
654
      final String markup = String.format( "%n%n%s ", hashes );
655
      final String text = get( "Main.menu.insert.header_" + i );
656
      final String accelerator = "Shortcut+" + i;
657
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
658
659
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
660
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
661
        activeFileEditorIsNull );
662
    }
663
664
    Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
665
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
666
      activeFileEditorIsNull );
667
    Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
668
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
669
      activeFileEditorIsNull );
670
    Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
671
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
672
      activeFileEditorIsNull );
673
674
    // Help actions
675
    Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
676
677
    //---- MenuBar ----
678
    Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
679
      fileNewAction,
680
      fileOpenAction,
681
      null,
682
      fileCloseAction,
683
      fileCloseAllAction,
684
      null,
685
      fileSaveAction,
686
      fileSaveAllAction,
687
      null,
688
      fileExitAction );
689
690
    Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
691
      editUndoAction,
692
      editRedoAction,
693
      editFindAction,
694
      editReplaceAction,
695
      editFindNextAction,
696
      editFindPreviousAction );
697
698
    Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
699
      insertBoldAction,
700
      insertItalicAction,
701
      insertSuperscriptAction,
702
      insertSubscriptAction,
703
      insertStrikethroughAction,
704
      insertBlockquoteAction,
705
      insertCodeAction,
706
      insertFencedCodeBlockAction,
707
      null,
708
      insertLinkAction,
709
      insertImageAction,
710
      null,
711
      headers[ 0 ],
712
      headers[ 1 ],
713
      headers[ 2 ],
714
      headers[ 3 ],
715
      headers[ 4 ],
716
      headers[ 5 ],
717
      null,
718
      insertUnorderedListAction,
719
      insertOrderedListAction,
720
      insertHorizontalRuleAction );
721
722
    Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
723
      helpAboutAction );
724
725
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
726
727
    //---- ToolBar ----
728
    ToolBar toolBar = ActionUtils.createToolBar(
729
      fileNewAction,
730
      fileOpenAction,
731
      fileSaveAction,
732
      null,
733
      editUndoAction,
734
      editRedoAction,
735
      null,
736
      insertBoldAction,
737
      insertItalicAction,
738
      insertSuperscriptAction,
739
      insertSubscriptAction,
740
      insertBlockquoteAction,
741
      insertCodeAction,
742
      insertFencedCodeBlockAction,
743
      null,
744
      insertLinkAction,
745
      insertImageAction,
746
      null,
747
      headers[ 0 ],
748
      null,
749
      insertUnorderedListAction,
750
      insertOrderedListAction );
751
752
    return new VBox( menuBar, toolBar );
753
  }
754
755
  /**
756
   * Creates a boolean property that is bound to another boolean value of the
757
   * active editor.
758
   */
759
  private BooleanProperty createActiveBooleanProperty(
760
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
761
762
    final BooleanProperty b = new SimpleBooleanProperty();
763
    final FileEditorTab tab = getActiveFileEditor();
764
765
    if( tab != null ) {
766
      b.bind( func.apply( tab ) );
767
    }
768
769
    getFileEditorPane().activeFileEditorProperty().addListener(
770
      (observable, oldFileEditor, newFileEditor) -> {
771
        b.unbind();
772
773
        if( newFileEditor != null ) {
774
          b.bind( func.apply( newFileEditor ) );
775
        } else {
776
          b.set( false );
777
        }
778
      }
779
    );
780
781
    return b;
782
  }
783
784
  private void initLayout() {
785
    final SplitPane splitPane = new SplitPane(
786
      getDefinitionPane().getNode(),
787
      getFileEditorPane().getNode(),
788
      getPreviewPane().getNode() );
789
790
    splitPane.setDividerPositions(
791
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
792
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
793
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
794
795
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
796
    final BorderPane borderPane = new BorderPane();
797
    borderPane.setPrefSize( 1024, 800 );
798
    borderPane.setTop( createMenuBar() );
799
    borderPane.setBottom( getStatusBar() );
800
    borderPane.setCenter( splitPane );
801
802
    final Scene appScene = new Scene( borderPane );
803
    setScene( appScene );
804
    appScene.getStylesheets().add( STYLESHEET_SCENE );
805
    appScene.windowProperty().addListener(
806
      (observable, oldWindow, newWindow) -> {
807
        newWindow.setOnCloseRequest( e -> {
808
          if( !getFileEditorPane().closeAllEditors() ) {
809
            e.consume();
810
          }
811
        } );
812
813
        // Workaround JavaFX bug: deselect menubar if window loses focus.
814
        newWindow.focusedProperty().addListener(
815
          (obs, oldFocused, newFocused) -> {
816
            if( !newFocused ) {
817
              // Send an ESC key event to the menubar
818
              this.menuBar.fireEvent(
819
                new KeyEvent(
820
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
821
                  false, false, false, false ) );
822
            }
823
          }
824
        );
825
      }
826
    );
827
  }
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
88
/**
89
 * Main window containing a tab pane in the center for file editors.
90
 *
91
 * @author Karl Tauber and White Magic Software, Ltd.
92
 */
93
public class MainWindow implements Observer {
94
95
  private final Options options = Services.load( Options.class );
96
  private final Snitch snitch = Services.load( Snitch.class );
97
  private final Notifier notifier = Services.load( Notifier.class );
98
99
  private Scene scene;
100
  private MenuBar menuBar;
101
  private StatusBar statusBar;
102
103
  private DefinitionSource definitionSource;
104
  private DefinitionPane definitionPane;
105
  private FileEditorTabPane fileEditorPane;
106
  private HTMLPreviewPane previewPane;
107
108
  /**
109
   * Prevent re-instantiation processing classes.
110
   */
111
  private Map<FileEditorTab, Processor<String>> processors;
112
113
  public MainWindow() {
114
    initLayout();
115
    initDefinitionListener();
116
    initTabAddedListener();
117
    initTabChangedListener();
118
    initPreferences();
119
    initSnitch();
120
  }
121
122
  /**
123
   * Listen for file editor tab pane to receive an open definition source event.
124
   */
125
  private void initDefinitionListener() {
126
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
127
      (ObservableValue<? extends Path> definitionFile,
128
        final Path oldPath, final Path newPath) -> {
129
        openDefinition( newPath );
130
131
        // Indirectly refresh the resolved map.
132
        setProcessors( null );
133
134
        // Will create new processors and therefore a new resolved map.
135
        refreshSelectedTab( getActiveFileEditor() );
136
137
        updateDefinitionPane();
138
      }
139
    );
140
  }
141
142
  /**
143
   * When tabs are added, hook the various change listeners onto the new tab so
144
   * that the preview pane refreshes as necessary.
145
   */
146
  private void initTabAddedListener() {
147
    final FileEditorTabPane editorPane = getFileEditorPane();
148
149
    // Make sure the text processor kicks off when new files are opened.
150
    final ObservableList<Tab> tabs = editorPane.getTabs();
151
152
    // Update the preview pane on tab changes.
153
    tabs.addListener(
154
      (final Change<? extends Tab> change) -> {
155
        while( change.next() ) {
156
          if( change.wasAdded() ) {
157
            // Multiple tabs can be added simultaneously.
158
            for( final Tab newTab : change.getAddedSubList() ) {
159
              final FileEditorTab tab = (FileEditorTab)newTab;
160
161
              initTextChangeListener( tab );
162
              initCaretParagraphListener( tab );
163
              initVariableNameInjector( tab );
164
//              initSyntaxListener( tab );
165
            }
166
          }
167
        }
168
      }
169
    );
170
  }
171
172
  /**
173
   * Reloads the preferences from the previous load.
174
   */
175
  private void initPreferences() {
176
    restoreDefinitionSource();
177
    getFileEditorPane().restorePreferences();
178
    updateDefinitionPane();
179
  }
180
181
  /**
182
   * Listen for new tab selection events.
183
   */
184
  private void initTabChangedListener() {
185
    final FileEditorTabPane editorPane = getFileEditorPane();
186
187
    // Update the preview pane changing tabs.
188
    editorPane.addTabSelectionListener(
189
      (ObservableValue<? extends Tab> tabPane,
190
        final Tab oldTab, final Tab newTab) -> {
191
192
        // If there was no old tab, then this is a first time load, which
193
        // can be ignored.
194
        if( oldTab != null ) {
195
          if( newTab == null ) {
196
            closeRemainingTab();
197
          }
198
          else {
199
            // Update the preview with the edited text.
200
            refreshSelectedTab( (FileEditorTab)newTab );
201
          }
202
        }
203
      }
204
    );
205
  }
206
207
  private void initTextChangeListener( final FileEditorTab tab ) {
208
    tab.addTextChangeListener(
209
      (ObservableValue<? extends String> editor,
210
        final String oldValue, final String newValue) -> {
211
        refreshSelectedTab( tab );
212
      }
213
    );
214
  }
215
216
  private void initCaretParagraphListener( final FileEditorTab tab ) {
217
    tab.addCaretParagraphListener(
218
      (ObservableValue<? extends Integer> editor,
219
        final Integer oldValue, final Integer newValue) -> {
220
        refreshSelectedTab( tab );
221
      }
222
    );
223
  }
224
225
  private void initVariableNameInjector( final FileEditorTab tab ) {
226
    VariableNameInjector.listen( tab, getDefinitionPane() );
227
  }
228
229
  /**
230
   * Watch for changes to external files. In particular, this awaits
231
   * modifications to any XSL files associated with XML files being edited. When
232
   * an XSL file is modified (external to the application), the snitch's ears
233
   * perk up and the file is reloaded. This keeps the XSL transformation up to
234
   * date with what's on the file system.
235
   */
236
  private void initSnitch() {
237
    getSnitch().addObserver( this );
238
  }
239
240
  /**
241
   * Called whenever the preview pane becomes out of sync with the file editor
242
   * tab. This can be called when the text changes, the caret paragraph changes,
243
   * or the file tab changes.
244
   *
245
   * @param tab The file editor tab that has been changed in some fashion.
246
   */
247
  private void refreshSelectedTab( final FileEditorTab tab ) {
248
    if( tab.isFileOpen() ) {
249
      getPreviewPane().setPath( tab.getPath() );
250
251
      Processor<String> processor = getProcessors().get( tab );
252
253
      if( processor == null ) {
254
        processor = createProcessor( tab );
255
        getProcessors().put( tab, processor );
256
      }
257
258
      try {
259
        processor.processChain( tab.getEditorText() );
260
        getNotifier().clear();
261
      } catch( final Exception ex ) {
262
        error( ex );
263
      }
264
    }
265
  }
266
267
  /**
268
   * Returns the variable map of interpolated definitions.
269
   *
270
   * @return A map to help dereference variables.
271
   */
272
  private Map<String, String> getResolvedMap() {
273
    return getDefinitionSource().getResolvedMap();
274
  }
275
276
  /**
277
   * Returns the root node for the hierarchical definition source.
278
   *
279
   * @return Data to display in the definition pane.
280
   */
281
  private TreeView<String> getTreeView() {
282
    try {
283
      return getDefinitionSource().asTreeView();
284
    } catch( Exception e ) {
285
      error( e );
286
    }
287
288
    return new TreeView<>();
289
  }
290
291
  /**
292
   * Called when a definition source is opened.
293
   *
294
   * @param path Path to the definition source that was opened.
295
   */
296
  private void openDefinition( final Path path ) {
297
    try {
298
      final DefinitionSource ds = createDefinitionSource( path.toString() );
299
      setDefinitionSource( ds );
300
      storeDefinitionSource();
301
      updateDefinitionPane();
302
    } catch( final Exception e ) {
303
      error( e );
304
    }
305
  }
306
307
  private void updateDefinitionPane() {
308
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
309
  }
310
311
  private void restoreDefinitionSource() {
312
    final Preferences preferences = getPreferences();
313
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
314
315
    // If there's no definition source set, don't try to load it.
316
    if( source != null ) {
317
      setDefinitionSource( createDefinitionSource( source ) );
318
    }
319
  }
320
321
  private void storeDefinitionSource() {
322
    final Preferences preferences = getPreferences();
323
    final DefinitionSource ds = getDefinitionSource();
324
325
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
326
  }
327
328
  /**
329
   * Called when the last open tab is closed to clear the preview pane.
330
   */
331
  private void closeRemainingTab() {
332
    getPreviewPane().clear();
333
  }
334
335
  /**
336
   * Called when an exception occurs that warrants the user's attention.
337
   *
338
   * @param e The exception with a message that the user should know about.
339
   */
340
  private void error( final Exception e ) {
341
    getNotifier().notify( e );
342
  }
343
344
  //---- File actions -------------------------------------------------------
345
  /**
346
   * Called when an observable instance has changed. This includes the snitch
347
   * service and the notify service.
348
   *
349
   * @param observable The observed instance.
350
   * @param value The noteworthy item.
351
   */
352
  @Override
353
  public void update( final Observable observable, final Object value ) {
354
    if( value != null ) {
355
      if( observable instanceof Snitch && value instanceof Path ) {
356
        update( (Path)value );
357
      }
358
      else if( observable instanceof Notifier && value instanceof String ) {
359
        final String s = (String)value;
360
        final int index = s.indexOf( '\n' );
361
        final String message = s.substring( 0, index > 0 ? index : s.length() );
362
363
        getStatusBar().setText( message );
364
      }
365
    }
366
  }
367
368
  /**
369
   * Called when a file has been modified.
370
   *
371
   * @param file Path to the modified file.
372
   */
373
  private void update( final Path file ) {
374
    // Avoid throwing IllegalStateException by running from a non-JavaFX thread.
375
    Platform.runLater(
376
      () -> {
377
        // Brute-force XSLT file reload by re-instantiating all processors.
378
        resetProcessors();
379
        refreshSelectedTab( getActiveFileEditor() );
380
      }
381
    );
382
  }
383
384
  /**
385
   * After resetting the processors, they will refresh anew to be up-to-date
386
   * with the files (text and definition) currently loaded into the editor.
387
   */
388
  private void resetProcessors() {
389
    getProcessors().clear();
390
  }
391
392
  //---- File actions -------------------------------------------------------
393
  private void fileNew() {
394
    getFileEditorPane().newEditor();
395
  }
396
397
  private void fileOpen() {
398
    getFileEditorPane().openFileDialog();
399
  }
400
401
  private void fileClose() {
402
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
403
  }
404
405
  private void fileCloseAll() {
406
    getFileEditorPane().closeAllEditors();
407
  }
408
409
  private void fileSave() {
410
    getFileEditorPane().saveEditor( getActiveFileEditor() );
411
  }
412
413
  private void fileSaveAll() {
414
    getFileEditorPane().saveAllEditors();
415
  }
416
417
  private void fileExit() {
418
    final Window window = getWindow();
419
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
420
  }
421
422
  //---- Help actions -------------------------------------------------------
423
  private void helpAbout() {
424
    Alert alert = new Alert( AlertType.INFORMATION );
425
    alert.setTitle( get( "Dialog.about.title" ) );
426
    alert.setHeaderText( get( "Dialog.about.header" ) );
427
    alert.setContentText( get( "Dialog.about.content" ) );
428
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
429
    alert.initOwner( getWindow() );
430
431
    alert.showAndWait();
432
  }
433
434
  //---- Convenience accessors ----------------------------------------------
435
  private float getFloat( final String key, final float defaultValue ) {
436
    return getPreferences().getFloat( key, defaultValue );
437
  }
438
439
  private Preferences getPreferences() {
440
    return getOptions().getState();
441
  }
442
443
  protected Scene getScene() {
444
    if( this.scene == null ) {
445
      this.scene = createScene();
446
    }
447
448
    return this.scene;
449
  }
450
451
  public Window getWindow() {
452
    return getScene().getWindow();
453
  }
454
455
  private MarkdownEditorPane getActiveEditor() {
456
    final EditorPane pane = getActiveFileEditor().getEditorPane();
457
458
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
459
  }
460
461
  private FileEditorTab getActiveFileEditor() {
462
    return getFileEditorPane().getActiveFileEditor();
463
  }
464
465
  //---- Member accessors ---------------------------------------------------
466
  private void setScene( Scene scene ) {
467
    this.scene = scene;
468
  }
469
470
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
471
    this.processors = map;
472
  }
473
474
  private Map<FileEditorTab, Processor<String>> getProcessors() {
475
    if( this.processors == null ) {
476
      setProcessors( new HashMap<>() );
477
    }
478
479
    return this.processors;
480
  }
481
482
  private FileEditorTabPane getFileEditorPane() {
483
    if( this.fileEditorPane == null ) {
484
      this.fileEditorPane = createFileEditorPane();
485
    }
486
487
    return this.fileEditorPane;
488
  }
489
490
  private HTMLPreviewPane getPreviewPane() {
491
    if( this.previewPane == null ) {
492
      this.previewPane = createPreviewPane();
493
    }
494
495
    return this.previewPane;
496
  }
497
498
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
499
    this.definitionSource = definitionSource;
500
  }
501
502
  private DefinitionSource getDefinitionSource() {
503
    if( this.definitionSource == null ) {
504
      this.definitionSource = new EmptyDefinitionSource();
505
    }
506
507
    return this.definitionSource;
508
  }
509
510
  private DefinitionPane getDefinitionPane() {
511
    if( this.definitionPane == null ) {
512
      this.definitionPane = createDefinitionPane();
513
    }
514
515
    return this.definitionPane;
516
  }
517
518
  private Options getOptions() {
519
    return this.options;
520
  }
521
522
  private Snitch getSnitch() {
523
    return this.snitch;
524
  }
525
526
  private Notifier getNotifier() {
527
    return this.notifier;
528
  }
529
530
  public void setMenuBar( final MenuBar menuBar ) {
531
    this.menuBar = menuBar;
532
  }
533
534
  public MenuBar getMenuBar() {
535
    return this.menuBar;
536
  }
537
538
  private synchronized StatusBar getStatusBar() {
539
    if( this.statusBar == null ) {
540
      this.statusBar = createStatusBar();
541
    }
542
543
    return this.statusBar;
544
  }
545
546
  //---- Member creators ----------------------------------------------------
547
  /**
548
   * Factory to create processors that are suited to different file types.
549
   *
550
   * @param tab The tab that is subjected to processing.
551
   *
552
   * @return A processor suited to the file type specified by the tab's path.
553
   */
554
  private Processor<String> createProcessor( final FileEditorTab tab ) {
555
    return createProcessorFactory().createProcessor( tab );
556
  }
557
558
  private ProcessorFactory createProcessorFactory() {
559
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
560
  }
561
562
  private DefinitionSource createDefinitionSource( final String path ) {
563
    return createDefinitionFactory().createDefinitionSource( path );
564
  }
565
566
  /**
567
   * Create an editor pane to hold file editor tabs.
568
   *
569
   * @return A new instance, never null.
570
   */
571
  private FileEditorTabPane createFileEditorPane() {
572
    return new FileEditorTabPane();
573
  }
574
575
  private HTMLPreviewPane createPreviewPane() {
576
    return new HTMLPreviewPane();
577
  }
578
579
  private DefinitionPane createDefinitionPane() {
580
    return new DefinitionPane( getTreeView() );
581
  }
582
583
  private DefinitionFactory createDefinitionFactory() {
584
    return new DefinitionFactory();
585
  }
586
587
  private StatusBar createStatusBar() {
588
    return new StatusBar();
589
  }
590
591
  private Scene createScene() {
592
    final SplitPane splitPane = new SplitPane(
593
      getDefinitionPane().getNode(),
594
      getFileEditorPane().getNode(),
595
      getPreviewPane().getNode() );
596
597
    splitPane.setDividerPositions(
598
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
599
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
600
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
601
602
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
603
    final BorderPane borderPane = new BorderPane();
604
    borderPane.setPrefSize( 1024, 800 );
605
    borderPane.setTop( createMenuBar() );
606
    borderPane.setBottom( getStatusBar() );
607
    borderPane.setCenter( splitPane );
608
609
    final VBox box = new VBox();
610
    box.setAlignment( Pos.BASELINE_CENTER );
611
    box.getChildren().add( new Text( "Line %d of %d" ) );
612
    getStatusBar().getRightItems().add( box );
613
614
    return new Scene( borderPane );
615
  }
616
617
  private Node createMenuBar() {
618
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
619
620
    // File actions
621
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
622
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
623
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
624
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
625
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
626
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
627
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
628
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
629
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
630
631
    // Edit actions
632
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
633
      e -> getActiveEditor().undo(),
634
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
635
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
636
      e -> getActiveEditor().redo(),
637
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
638
    Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Shortcut+F", SEARCH,
639
      e -> getActiveEditor().find(),
640
      activeFileEditorIsNull );
641
    Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
642
      e -> getActiveEditor().replace(),
643
      activeFileEditorIsNull );
644
    Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
645
      e -> getActiveEditor().findNext(),
646
      activeFileEditorIsNull );
647
    Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
648
      e -> getActiveEditor().findPrevious(),
649
      activeFileEditorIsNull );
650
651
    // Insert actions
652
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
653
      e -> getActiveEditor().surroundSelection( "**", "**" ),
654
      activeFileEditorIsNull );
655
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
656
      e -> getActiveEditor().surroundSelection( "*", "*" ),
657
      activeFileEditorIsNull );
658
    Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
659
      e -> getActiveEditor().surroundSelection( "^", "^" ),
660
      activeFileEditorIsNull );
661
    Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
662
      e -> getActiveEditor().surroundSelection( "~", "~" ),
663
      activeFileEditorIsNull );
664
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
665
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
666
      activeFileEditorIsNull );
667
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
668
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
669
      activeFileEditorIsNull );
670
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
671
      e -> getActiveEditor().surroundSelection( "`", "`" ),
672
      activeFileEditorIsNull );
673
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
674
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
675
      activeFileEditorIsNull );
676
677
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
678
      e -> getActiveEditor().insertLink(),
679
      activeFileEditorIsNull );
680
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
681
      e -> getActiveEditor().insertImage(),
682
      activeFileEditorIsNull );
683
684
    final Action[] headers = new Action[ 6 ];
685
686
    // Insert header actions (H1 ... H6)
687
    for( int i = 1; i <= 6; i++ ) {
688
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
689
      final String markup = String.format( "%n%n%s ", hashes );
690
      final String text = get( "Main.menu.insert.header_" + i );
691
      final String accelerator = "Shortcut+" + i;
692
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
693
694
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
695
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
696
        activeFileEditorIsNull );
697
    }
698
699
    Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
700
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
701
      activeFileEditorIsNull );
702
    Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
703
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
704
      activeFileEditorIsNull );
705
    Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
706
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
707
      activeFileEditorIsNull );
708
709
    // Help actions
710
    Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
711
712
    //---- MenuBar ----
713
    Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
714
      fileNewAction,
715
      fileOpenAction,
716
      null,
717
      fileCloseAction,
718
      fileCloseAllAction,
719
      null,
720
      fileSaveAction,
721
      fileSaveAllAction,
722
      null,
723
      fileExitAction );
724
725
    Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
726
      editUndoAction,
727
      editRedoAction,
728
      editFindAction,
729
      editReplaceAction,
730
      editFindNextAction,
731
      editFindPreviousAction );
732
733
    Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
734
      insertBoldAction,
735
      insertItalicAction,
736
      insertSuperscriptAction,
737
      insertSubscriptAction,
738
      insertStrikethroughAction,
739
      insertBlockquoteAction,
740
      insertCodeAction,
741
      insertFencedCodeBlockAction,
742
      null,
743
      insertLinkAction,
744
      insertImageAction,
745
      null,
746
      headers[ 0 ],
747
      headers[ 1 ],
748
      headers[ 2 ],
749
      headers[ 3 ],
750
      headers[ 4 ],
751
      headers[ 5 ],
752
      null,
753
      insertUnorderedListAction,
754
      insertOrderedListAction,
755
      insertHorizontalRuleAction );
756
757
    Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
758
      helpAboutAction );
759
760
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
761
762
    //---- ToolBar ----
763
    ToolBar toolBar = ActionUtils.createToolBar(
764
      fileNewAction,
765
      fileOpenAction,
766
      fileSaveAction,
767
      null,
768
      editUndoAction,
769
      editRedoAction,
770
      null,
771
      insertBoldAction,
772
      insertItalicAction,
773
      insertSuperscriptAction,
774
      insertSubscriptAction,
775
      insertBlockquoteAction,
776
      insertCodeAction,
777
      insertFencedCodeBlockAction,
778
      null,
779
      insertLinkAction,
780
      insertImageAction,
781
      null,
782
      headers[ 0 ],
783
      null,
784
      insertUnorderedListAction,
785
      insertOrderedListAction );
786
787
    return new VBox( menuBar, toolBar );
788
  }
789
790
  /**
791
   * Creates a boolean property that is bound to another boolean value of the
792
   * active editor.
793
   */
794
  private BooleanProperty createActiveBooleanProperty(
795
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
796
797
    final BooleanProperty b = new SimpleBooleanProperty();
798
    final FileEditorTab tab = getActiveFileEditor();
799
800
    if( tab != null ) {
801
      b.bind( func.apply( tab ) );
802
    }
803
804
    getFileEditorPane().activeFileEditorProperty().addListener(
805
      (observable, oldFileEditor, newFileEditor) -> {
806
        b.unbind();
807
808
        if( newFileEditor != null ) {
809
          b.bind( func.apply( newFileEditor ) );
810
        }
811
        else {
812
          b.set( false );
813
        }
814
      }
815
    );
816
817
    return b;
818
  }
819
820
  private void initLayout() {
821
    final Scene appScene = getScene();
822
823
    appScene.getStylesheets().add( STYLESHEET_SCENE );
824
//    appScene.getStylesheets().add( STYLESHEET_XML );
825
826
    appScene.windowProperty().addListener(
827
      (observable, oldWindow, newWindow) -> {
828
        newWindow.setOnCloseRequest( e -> {
829
          if( !getFileEditorPane().closeAllEditors() ) {
830
            e.consume();
831
          }
832
        } );
833
834
        // Workaround JavaFX bug: deselect menubar if window loses focus.
835
        newWindow.focusedProperty().addListener(
836
          (obs, oldFocused, newFocused) -> {
837
            if( !newFocused ) {
838
              // Send an ESC key event to the menubar
839
              this.menuBar.fireEvent(
840
                new KeyEvent(
841
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
842
                  false, false, false, false ) );
843
            }
844
          }
845
        );
846
      }
847
    );
848
  }
849
850
//  private void initSyntaxListener( final FileEditorTab tab ) {
851
//    tab.addTextChangeListener(
852
//      (ObservableValue<? extends String> observable,
853
//        final String oText, final String nText) -> {
854
//        tab.getEditorPane().getEditor().setStyleSpans( 0, highlight( nText ) );
855
//      }
856
//    );
857
//  }
858
//
859
//  private static final Pattern XML_TAG = Pattern.compile( "(?<ELEMENT>(</?\\h*)(\\w+)([^<>]*)(\\h*/?>))"
860
//    + "|(?<COMMENT><!--[^<>]+-->)" );
861
//
862
//  private static final Pattern ATTRIBUTES = Pattern.compile( "(\\w+\\h*)(=)(\\h*\"[^\"]+\")" );
863
//
864
//  private static final int GROUP_OPEN_BRACKET = 2;
865
//  private static final int GROUP_ELEMENT_NAME = 3;
866
//  private static final int GROUP_ATTRIBUTES_SECTION = 4;
867
//  private static final int GROUP_CLOSE_BRACKET = 5;
868
//  private static final int GROUP_ATTRIBUTE_NAME = 1;
869
//  private static final int GROUP_EQUAL_SYMBOL = 2;
870
//  private static final int GROUP_ATTRIBUTE_VALUE = 3;
871
//
872
//  private static StyleSpans<Collection<String>> highlight( final String text ) {
873
//    final Matcher matcher = XML_TAG.matcher( text );
874
//    int lastKwEnd = 0;
875
//    final StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();
876
//
877
//    while( matcher.find() ) {
878
//      spansBuilder.add( Collections.emptyList(), matcher.start() - lastKwEnd );
879
//
880
//      if( matcher.group( "COMMENT" ) != null ) {
881
//        spansBuilder.add( Collections.singleton( "comment" ), matcher.end() - matcher.start() );
882
//      }
883
//      else if( matcher.group( "ELEMENT" ) != null ) {
884
//        String attributesText = matcher.group( GROUP_ATTRIBUTES_SECTION );
885
//
886
//        spansBuilder.add( Collections.singleton( "tagmark" ), matcher.end( GROUP_OPEN_BRACKET ) - matcher.start( GROUP_OPEN_BRACKET ) );
887
//        spansBuilder.add( Collections.singleton( "anytag" ), matcher.end( GROUP_ELEMENT_NAME ) - matcher.end( GROUP_OPEN_BRACKET ) );
888
//
889
//        if( !attributesText.isEmpty() ) {
890
//          lastKwEnd = 0;
891
//
892
//          final Matcher amatcher = ATTRIBUTES.matcher( attributesText );
893
//
894
//          while( amatcher.find() ) {
895
//            spansBuilder.add( Collections.emptyList(), amatcher.start() - lastKwEnd );
896
//            spansBuilder.add( Collections.singleton( "attribute" ), amatcher.end( GROUP_ATTRIBUTE_NAME ) - amatcher.start( GROUP_ATTRIBUTE_NAME ) );
897
//            spansBuilder.add( Collections.singleton( "tagmark" ), amatcher.end( GROUP_EQUAL_SYMBOL ) - amatcher.end( GROUP_ATTRIBUTE_NAME ) );
898
//            spansBuilder.add( Collections.singleton( "avalue" ), amatcher.end( GROUP_ATTRIBUTE_VALUE ) - amatcher.end( GROUP_EQUAL_SYMBOL ) );
899
//            lastKwEnd = amatcher.end();
900
//          }
901
//
902
//          if( attributesText.length() > lastKwEnd ) {
903
//            spansBuilder.add( Collections.emptyList(), attributesText.length() - lastKwEnd );
904
//          }
905
//        }
906
//
907
//        lastKwEnd = matcher.end( GROUP_ATTRIBUTES_SECTION );
908
//        spansBuilder.add( Collections.singleton( "tagmark" ), matcher.end( GROUP_CLOSE_BRACKET ) - lastKwEnd );
909
//      }
910
//
911
//      lastKwEnd = matcher.end();
912
//    }
913
//
914
//    spansBuilder.add( Collections.emptyList(), text.length() - lastKwEnd );
915
//    return spansBuilder.create();
916
//  }
828917
}
829918
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
9797
    final TreeItem<String> trunk,
9898
    final StringPredicate predicate ) {
99
    final List<TreeItem<String>> branches = trunk.getChildren();
10099
    TreeItem<String> result = null;
101100
102
    for( final TreeItem<String> leaf : branches ) {
103
      if( predicate.test( leaf.getValue() ) ) {
104
        result = leaf;
105
        break;
101
    if( trunk != null ) {
102
      final List<TreeItem<String>> branches = trunk.getChildren();
103
104
      for( final TreeItem<String> leaf : branches ) {
105
        if( predicate.test( leaf.getValue() ) ) {
106
          result = leaf;
107
          break;
108
        }
106109
      }
107110
    }
...
235238
   */
236239
  private TreeItem<String> sanitize( final TreeItem<String> item ) {
237
    final TreeItem<String> result = item == getTreeRoot()
238
      ? getFirst( item.getChildren() )
239
      : item;
240
    TreeItem<String> result;
240241
241
    return result == null ? item : result;
242
    if( item == null ) {
243
      result = getTreeRoot();
244
    }
245
    else {
246
      result = item == getTreeRoot()
247
        ? getFirst( item.getChildren() )
248
        : item;
249
    }
250
251
    return result;
242252
  }
243253
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
6464
import static org.fxmisc.wellbehaved.event.InputMap.consume;
6565
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
66
import static com.scrivenvar.util.Lists.getFirst;
67
import static com.scrivenvar.util.Lists.getLast;
68
import static java.lang.Character.isSpaceChar;
69
import static java.lang.Character.isWhitespace;
70
import static java.lang.Math.min;
71
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
72
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
73
import static org.fxmisc.wellbehaved.event.InputMap.consume;
74
75
/**
76
 * Provides the logic for injecting variable names within the editor.
77
 *
78
 * @author White Magic Software, Ltd.
79
 */
80
public class VariableNameInjector {
81
82
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
83
84
  private static final int NO_DIFFERENCE = -1;
85
86
  private final Settings settings = Services.load( Settings.class );
87
88
  /**
89
   * Used to capture keyboard events once the user presses @.
90
   */
91
  private InputMap<InputEvent> keyboardMap;
92
93
  private FileEditorTab tab;
94
  private DefinitionPane definitionPane;
95
96
  /**
97
   * Position of the variable in the text when in variable mode (0 by default).
98
   */
99
  private int initialCaretPosition;
100
101
  private VariableNameInjector() {
102
  }
103
104
  public static void listen( final FileEditorTab tab, final DefinitionPane pane ) {
105
    VariableNameInjector vni = new VariableNameInjector();
106
107
    vni.setFileEditorTab( tab );
108
    vni.setDefinitionPane( pane );
109
110
    vni.initKeyboardEventListeners();
111
  }
112
113
  /**
114
   * Traps keys for performing various short-cut tasks, such as @-mode variable
115
   * insertion and control+space for variable autocomplete.
116
   *
117
   * @ key is pressed, a new keyboard map is inserted in place of the current
118
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
119
   *
120
   * @see createKeyboardMap()
121
   */
122
  private void initKeyboardEventListeners() {
123
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
124
125
    // @ key in Linux?
126
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
127
    // @ key in Windows.
128
    addEventListener( keyPressed( AT ), this::vMode );
129
  }
130
131
  /**
132
   * The @ symbol is a short-cut to inserting a YAML variable reference.
133
   *
134
   * @param e Superfluous information about the key that was pressed.
135
   */
136
  private void vMode( KeyEvent e ) {
137
    setInitialCaretPosition();
138
    vModeStart();
139
    vModeAutocomplete();
140
  }
141
142
  /**
143
   * Receives key presses until the user completes the variable selection. This
144
   * allows the arrow keys to be used for selecting variables.
145
   *
146
   * @param e The key that was pressed.
147
   */
148
  private void vModeKeyPressed( KeyEvent e ) {
149
    final KeyCode keyCode = e.getCode();
150
151
    switch( keyCode ) {
152
      case BACK_SPACE:
153
        // Don't decorate the variable upon exiting vMode.
154
        vModeBackspace();
155
        break;
156
157
      case ESCAPE:
158
        // Don't decorate the variable upon exiting vMode.
159
        vModeStop();
160
        break;
161
162
      case ENTER:
163
      case PERIOD:
164
      case RIGHT:
165
      case END:
166
        // Stop at a leaf node, ENTER means accept.
167
        if( vModeConditionalComplete() && keyCode == ENTER ) {
168
          vModeStop();
169
170
          // Decorate the variable upon exiting vMode.
171
          decorateVariable();
172
        }
173
        break;
174
175
      case UP:
176
        cyclePathPrev();
177
        break;
178
179
      case DOWN:
180
        cyclePathNext();
181
        break;
182
183
      default:
184
        vModeFilterKeyPressed( e );
185
        break;
186
    }
187
188
    e.consume();
189
  }
190
191
  private void vModeBackspace() {
192
    deleteSelection();
193
194
    // Break out of variable mode by back spacing to the original position.
195
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
196
      vModeAutocomplete();
197
    } else {
198
      vModeStop();
199
    }
200
  }
201
202
  /**
203
   * Updates the text with the path selected (or typed) by the user.
204
   */
205
  private void vModeAutocomplete() {
206
    final TreeItem<String> node = getCurrentNode();
207
208
    if( !node.isLeaf() ) {
209
      final String word = getLastPathWord();
210
      final String label = node.getValue();
211
      final int delta = difference( label, word );
212
      final String remainder = delta == NO_DIFFERENCE
213
        ? label
214
        : label.substring( delta );
215
216
      final StyledTextArea textArea = getEditor();
217
      final int posBegan = getCurrentCaretPosition();
218
      final int posEnded = posBegan + remainder.length();
219
220
      textArea.replaceSelection( remainder );
221
222
      if( posEnded - posBegan > 0 ) {
223
        textArea.selectRange( posEnded, posBegan );
224
      }
225
226
      expand( node );
227
    }
228
  }
229
230
  /**
231
   * Only variable name keys can pass through the filter. This is called when
232
   * the user presses a key.
233
   *
234
   * @param e The key that was pressed.
235
   */
236
  private void vModeFilterKeyPressed( final KeyEvent e ) {
237
    if( isVariableNameKey( e ) ) {
238
      typed( e.getText() );
239
    }
240
  }
241
242
  /**
243
   * Performs an autocomplete depending on whether the user has finished typing
244
   * in a word. If there is a selected range, then this will complete the most
245
   * recent word and jump to the next child.
246
   *
247
   * @return true The auto-completed node was a terminal node.
248
   */
249
  private boolean vModeConditionalComplete() {
250
    acceptPath();
251
252
    final TreeItem<String> node = getCurrentNode();
253
    final boolean terminal = isTerminal( node );
254
255
    if( !terminal ) {
256
      typed( SEPARATOR );
257
    }
258
259
    return terminal;
260
  }
261
262
  /**
263
   * Pressing control+space will find a node that matches the current word and
264
   * substitute the YAML variable reference. This is called when the user is not
265
   * editing in vMode.
266
   *
267
   * @param e Ignored -- it can only be Ctrl+Space.
268
   */
269
  private void autocomplete( final KeyEvent e ) {
270
    final String paragraph = getCaretParagraph();
271
    final int[] boundaries = getWordBoundaries( paragraph );
272
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
273
274
    final VariableTreeItem<String> leaf = findLeaf( word );
275
276
    if( leaf != null ) {
277
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
278
      decorateVariable();
279
      expand( leaf );
280
    }
281
  }
282
283
  /**
284
   * Called when autocomplete finishes on a valid leaf or when the user presses
285
   * Enter to finish manual autocomplete.
286
   */
287
  private void decorateVariable() {
288
    // A little bit of duplication...
289
    final String paragraph = getCaretParagraph();
290
    final int[] boundaries = getWordBoundaries( paragraph );
291
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
292
293
    final String newVariable = getVariableDecorator().decorate( old );
294
295
    final int posEnded = getCurrentCaretPosition();
296
    final int posBegan = posEnded - old.length();
297
298
    getEditor().replaceText( posBegan, posEnded, newVariable );
299
  }
300
301
  /**
302
   * Updates the text at the given position within the current paragraph.
303
   *
304
   * @param posBegan The starting index in the paragraph text to replace.
305
   * @param posEnded The ending index in the paragraph text to replace.
306
   * @param text Overwrite the paragraph substring with this text.
307
   */
308
  private void replaceText(
309
    final int posBegan, final int posEnded, final String text ) {
310
    final int p = getCurrentParagraph();
311
312
    getEditor().replaceText( p, posBegan, p, posEnded, text );
313
  }
314
315
  /**
316
   * Returns the caret's current paragraph position.
317
   *
318
   * @return A number greater than or equal to 0.
319
   */
320
  private int getCurrentParagraph() {
321
    return getEditor().getCurrentParagraph();
322
  }
323
324
  /**
325
   * Returns current word boundary indexes into the current paragraph, including
326
   * punctuation.
327
   *
328
   * @param p The paragraph wherein to hunt word boundaries.
329
   * @param offset The offset into the paragraph to begin scanning left and
330
   * right.
331
   *
332
   * @return The starting and ending index of the word closest to the caret.
333
   */
334
  private int[] getWordBoundaries( final String p, final int offset ) {
335
    // Remove dashes, but retain hyphens. Retain same number of characters
336
    // to preserve relative indexes.
337
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
338
339
    return getWordAt( paragraph, offset );
340
  }
341
342
  /**
343
   * Helper method to get the word boundaries for the current paragraph.
344
   *
345
   * @param paragraph
346
   *
347
   * @return
348
   */
349
  private int[] getWordBoundaries( final String paragraph ) {
350
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
351
  }
352
353
  /**
354
   * Given an arbitrary offset into a string, this returns the word at that
355
   * index. The inputs and outputs include:
356
   *
357
   * <ul>
358
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
359
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
360
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
361
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
362
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
363
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
364
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
365
   * </ul>
366
   *
367
   * @param p The string to scan for a word.
368
   * @param offset The offset within s to begin searching for the nearest word
369
   * boundary, must not be out of bounds of s.
370
   *
371
   * @return The word in s at the offset.
372
   *
373
   * @see getWordBegan( String, int )
374
   * @see getWordEnded( String, int )
375
   */
376
  private int[] getWordAt( final String p, final int offset ) {
377
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
378
  }
379
380
  /**
381
   * Returns the index into s where a word begins.
382
   *
383
   * @param s Never null.
384
   * @param offset Index into s to begin searching backwards for a word
385
   * boundary.
386
   *
387
   * @return The index where a word begins.
388
   */
389
  private int getWordBegan( final String s, int offset ) {
390
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
391
      offset--;
392
    }
393
394
    return offset;
395
  }
396
397
  /**
398
   * Returns the index into s where a word ends.
399
   *
400
   * @param s Never null.
401
   * @param offset Index into s to begin searching forwards for a word boundary.
402
   *
403
   * @return The index where a word ends.
404
   */
405
  private int getWordEnded( final String s, int offset ) {
406
    final int length = s.length();
407
408
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
409
      offset++;
410
    }
411
412
    return offset;
413
  }
414
415
  /**
416
   * Returns true if the given character can be reasonably expected to be part
417
   * of a word, including punctuation marks.
418
   *
419
   * @param c The character to compare.
420
   *
421
   * @return false The character is a space character.
422
   */
423
  private boolean isBoundary( final char c ) {
424
    return !isSpaceChar( c );
425
  }
426
427
  /**
428
   * Returns the text for the paragraph that contains the caret.
429
   *
430
   * @return A non-null string, possibly empty.
431
   */
432
  private String getCaretParagraph() {
433
    return getEditor().getText( getCurrentParagraph() );
434
  }
435
436
  /**
437
   * Returns true if the node has children that can be selected (i.e., any
438
   * non-leaves).
439
   *
440
   * @param <T> The type that the TreeItem contains.
441
   * @param node The node to test for terminality.
442
   *
443
   * @return true The node has one branch and its a leaf.
444
   */
445
  private <T> boolean isTerminal( final TreeItem<T> node ) {
446
    final ObservableList<TreeItem<T>> branches = node.getChildren();
447
448
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
449
  }
450
451
  /**
452
   * Inserts text that the user typed at the current caret position, then
453
   * performs an autocomplete for the variable name.
454
   *
455
   * @param text The text to insert, never null.
456
   */
457
  private void typed( final String text ) {
458
    getEditor().replaceSelection( text );
459
    vModeAutocomplete();
460
  }
461
462
  /**
463
   * Called when the user presses either End or Enter key.
464
   */
465
  private void acceptPath() {
466
    final IndexRange range = getSelectionRange();
467
468
    if( range != null ) {
469
      final int rangeEnd = range.getEnd();
470
      final StyledTextArea textArea = getEditor();
471
      textArea.deselect();
472
      textArea.moveTo( rangeEnd );
473
    }
474
  }
475
476
  /**
477
   * Replaces the entirety of the existing path (from the initial caret
478
   * position) with the given path.
479
   *
480
   * @param oldPath The path to replace.
481
   * @param newPath The replacement path.
482
   */
483
  private void replacePath( final String oldPath, final String newPath ) {
484
    final StyledTextArea textArea = getEditor();
485
    final int posBegan = getInitialCaretPosition();
486
    final int posEnded = posBegan + oldPath.length();
487
488
    textArea.deselect();
489
    textArea.replaceText( posBegan, posEnded, newPath );
490
  }
491
492
  /**
493
   * Called when the user presses the Backspace key.
494
   */
495
  private void deleteSelection() {
496
    final StyledTextArea textArea = getEditor();
497
    textArea.replaceSelection( "" );
498
    textArea.deletePreviousChar();
499
  }
500
501
  /**
502
   * Cycles the selected text through the nodes.
503
   *
504
   * @param direction true - next; false - previous
505
   */
506
  private void cycleSelection( final boolean direction ) {
507
    final TreeItem<String> node = getCurrentNode();
508
509
    // Find the sibling for the current selection and replace the current
510
    // selection with the sibling's value
511
    TreeItem< String> cycled = direction
512
      ? node.nextSibling()
513
      : node.previousSibling();
514
515
    // When cycling at the end (or beginning) of the list, jump to the first
516
    // (or last) sibling depending on the cycle direction.
517
    if( cycled == null ) {
518
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
519
    }
520
521
    final String path = getCurrentPath();
522
    final String cycledWord = cycled.getValue();
523
    final String word = getLastPathWord();
524
    final int index = path.indexOf( word );
525
    final String cycledPath = path.substring( 0, index ) + cycledWord;
526
527
    expand( cycled );
528
    replacePath( path, cycledPath );
529
  }
530
531
  /**
532
   * Cycles to the next sibling of the currently selected tree node.
533
   */
534
  private void cyclePathNext() {
535
    cycleSelection( true );
536
  }
537
538
  /**
539
   * Cycles to the previous sibling of the currently selected tree node.
540
   */
541
  private void cyclePathPrev() {
542
    cycleSelection( false );
543
  }
544
545
  /**
546
   * Returns the variable name (or as much as has been typed so far). Returns
547
   * all the characters from the initial caret column to the the first
548
   * whitespace character. This will return a path that contains zero or more
549
   * separators.
550
   *
551
   * @return A non-null string, possibly empty.
552
   */
553
  private String getCurrentPath() {
554
    final String s = extractTextChunk();
555
    final int length = s.length();
556
557
    int i = 0;
558
559
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
560
      i++;
561
    }
562
563
    return s.substring( 0, i );
564
  }
565
566
  private <T> ObservableList<TreeItem<T>> getSiblings(
567
    final TreeItem<T> item ) {
568
    final TreeItem<T> parent = item.getParent();
569
    return parent == null ? item.getChildren() : parent.getChildren();
570
  }
571
572
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
573
    return getFirst( getSiblings( item ), item );
574
  }
575
576
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
577
    return getLast( getSiblings( item ), item );
578
  }
579
580
  /**
581
   * Returns the caret position as an offset into the text.
582
   *
583
   * @return A value from 0 to the length of the text (minus one).
584
   */
585
  private int getCurrentCaretPosition() {
586
    return getEditor().getCaretPosition();
587
  }
588
589
  /**
590
   * Returns the caret position within the current paragraph.
591
   *
592
   * @return A value from 0 to the length of the current paragraph.
593
   */
594
  private int getCurrentCaretColumn() {
595
    return getEditor().getCaretColumn();
596
  }
597
598
  /**
599
   * Returns the last word from the path.
600
   *
601
   * @return The last token.
602
   */
603
  private String getLastPathWord() {
604
    String path = getCurrentPath();
605
606
    int i = path.indexOf( SEPARATOR_CHAR );
607
608
    while( i > 0 ) {
609
      path = path.substring( i + 1 );
610
      i = path.indexOf( SEPARATOR_CHAR );
611
    }
612
613
    return path;
614
  }
615
616
  /**
617
   * Returns text from the initial caret position until some arbitrarily long
618
   * number of characters. The number of characters extracted will be
619
   * getMaxVarLength, or fewer, depending on how many characters remain to be
620
   * extracted. The result from this method is trimmed to the first whitespace
621
   * character.
622
   *
623
   * @return A chunk of text that includes all the words representing a path,
624
   * and then some.
625
   */
626
  private String extractTextChunk() {
627
    final StyledTextArea textArea = getEditor();
628
    final int textBegan = getInitialCaretPosition();
629
    final int remaining = textArea.getLength() - textBegan;
630
    final int textEnded = min( remaining, getMaxVarLength() );
631
632
    return textArea.getText( textBegan, textEnded );
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
    }
633631
  }
634632
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
6464
6565
  public void init( final Path workingDirectory ) {
66
    // In Windows, path characters must be changed from escape chars.
6667
    eval( replace( ""
6768
      + "assign( 'anchor', as.Date( '$date.anchor$', format='%Y-%m-%d' ), envir = .GlobalEnv );"
68
      + "setwd( '" + workingDirectory + "' );"
69
      + "setwd( '" + workingDirectory.toString().replace( '\\', '/' ) + "' );"
6970
      + "source( '../bin/pluralize.R' );"
7071
      + "source( '../bin/common.R' )", getDefinitions() ) );
...
106107
        prevIndex = currIndex + 1;
107108
108
      } else {
109
      }
110
      else {
109111
        // TODO: Implement this.
110112
        // There was a starting prefix but no ending suffix. Ignore the
M src/main/java/com/scrivenvar/processors/ProcessorFactory.java
142142
    final Processor<String> tpc = getCommonProcessor();
143143
    final Processor<String> xmlp = new XMLProcessor( tpc, tab.getPath() );
144
    final Processor<String> xcip = createXMLInsertionProcessor( xmlp, caret );
145
    final Processor<String> dvp = new DefaultVariableProcessor( xcip, getResolvedMap() );
144
    final Processor<String> dvp = new DefaultVariableProcessor( xmlp, getResolvedMap() );
145
    final Processor<String> xcip = createXMLInsertionProcessor( dvp, caret );
146146
147
    return dvp;
147
    return xcip;
148148
  }
149149
...
162162
    final Processor<String> tpc = getCommonProcessor();
163163
    final Processor<String> xmlp = new XMLProcessor( tpc, tab.getPath() );
164
    final Processor<String> xcip = createXMLInsertionProcessor( xmlp, caret );
165
    final Processor<String> rp = new InlineRProcessor( xcip, getResolvedMap(), tab.getPath() );
164
    final Processor<String> rp = new InlineRProcessor( xmlp, getResolvedMap(), tab.getPath() );
166165
    final Processor<String> rvp = new RVariableProcessor( rp, getResolvedMap() );
166
    final Processor<String> xcip = createXMLInsertionProcessor( rvp, caret );
167167
168
    return rvp;
168
    return xcip;
169169
  }
170170
M src/main/java/com/scrivenvar/processors/XMLProcessor.java
4343
import javax.xml.stream.events.ProcessingInstruction;
4444
import javax.xml.stream.events.XMLEvent;
45
import javax.xml.transform.ErrorListener;
4546
import javax.xml.transform.Source;
4647
import javax.xml.transform.Transformer;
4748
import javax.xml.transform.TransformerConfigurationException;
49
import javax.xml.transform.TransformerException;
4850
import javax.xml.transform.TransformerFactory;
4951
import javax.xml.transform.stream.StreamResult;
...
6365
 * @author White Magic Software, Ltd.
6466
 */
65
public class XMLProcessor extends AbstractProcessor<String> {
67
public class XMLProcessor extends AbstractProcessor<String>
68
  implements ErrorListener {
6669
6770
  private final Snitch snitch = Services.load( Snitch.class );
...
155158
  }
156159
160
  /**
161
   * Creates a configured transformer ready to run.
162
   *
163
   * @param xsl The stylesheet to use for transforming XML documents.
164
   *
165
   * @return The edited XML document transformed into another format (usually
166
   * markdown).
167
   *
168
   * @throws TransformerConfigurationException Could not create the transformer.
169
   */
157170
  protected Transformer createTransformer( final Path xsl )
158171
    throws TransformerConfigurationException {
159172
    final Source xslt = new StreamSource( xsl.toFile() );
173
160174
    return getTransformerFactory().newTransformer( xslt );
161175
  }
...
241255
   */
242256
  private TransformerFactory createTransformerFactory() {
243
    return new TransformerFactoryImpl();
257
    final TransformerFactory factory = new TransformerFactoryImpl();
258
259
    // Bubble problems up to the user interface, rather than standard error.
260
    factory.setErrorListener( this );
261
262
    return factory;
263
  }
264
265
  /**
266
   * Called when the XSL transformer issues a warning.
267
   *
268
   * @param ex The problem the transformer encountered.
269
   */
270
  @Override
271
  public void warning( final TransformerException ex ) {
272
    throw new RuntimeException( ex );
273
  }
274
275
  /**
276
   * Called when the XSL transformer issues an error.
277
   *
278
   * @param ex The problem the transformer encountered.
279
   */
280
  @Override
281
  public void error( final TransformerException ex ) {
282
    throw new RuntimeException( ex );
283
  }
284
285
  /**
286
   * Called when the XSL transformer issues a fatal error, which is probably
287
   * a bit over-dramatic a method name.
288
   *
289
   * @param ex The problem the transformer encountered.
290
   */
291
  @Override
292
  public void fatalError( final TransformerException ex ) {
293
    throw new RuntimeException( ex );
244294
  }
245295
M src/main/resources/com/scrivenvar/settings.properties
3333
file.stylesheet.markdown=${application.package}/editor/markdown.css
3434
file.stylesheet.preview=webview.css
35
file.stylesheet.xml=${application.package}/xml.css
3536
3637
file.logo.16 =${application.package}/logo16.png
A src/main/resources/com/scrivenvar/xml.css
1
.tagmark {
2
    -fx-fill: gray;
3
}
4
.anytag {
5
    -fx-fill: crimson;
6
}
7
.paren {
8
    -fx-fill: firebrick;
9
    -fx-font-weight: bold;
10
}
11
.attribute {
12
    -fx-fill: darkviolet;
13
}
14
.avalue {
15
    -fx-fill: black;
16
}
117
18
.comment {
19
	-fx-fill: teal;
20
}