Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
3333
}
3434
35
version = '1.1.2'
35
version = '1.1.3'
3636
applicationName = 'scrivenvar'
3737
mainClassName = 'com.scrivenvar.Main'
M src/main/java/com/scrivenvar/Constants.java
7575
  public static final String FILE_LOGO_512 = get( "file.logo.512" );
7676
77
  public static final String FILE_R_STARTUP = get( "file.r.startup" );
78
7779
  public static final String CARET_POSITION_BASE = get( "caret.token.base" );
7880
  public static final String CARET_POSITION_MD = get( "caret.token.markdown" );
...
100102
  // "OK" text
101103
  public static final String STATUS_BAR_DEFAULT = Messages.get( "Main.statusbar.state.default" );
102
103104
  public static final String STATUS_PARSE_ERROR = "Main.statusbar.parse.error";
105
  
104106
}
105107
M src/main/java/com/scrivenvar/MainWindow.java
3939
import com.scrivenvar.processors.ProcessorFactory;
4040
import com.scrivenvar.service.Options;
41
import com.scrivenvar.service.Snitch;
42
import com.scrivenvar.service.events.Notifier;
43
import com.scrivenvar.util.Action;
44
import com.scrivenvar.util.ActionUtils;
45
import static com.scrivenvar.util.StageState.*;
46
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
47
import java.io.IOException;
48
import java.nio.file.Path;
49
import java.util.HashMap;
50
import java.util.Map;
51
import java.util.Observable;
52
import java.util.Observer;
53
import java.util.function.Function;
54
import java.util.prefs.Preferences;
55
import javafx.application.Platform;
56
import javafx.beans.binding.Bindings;
57
import javafx.beans.binding.BooleanBinding;
58
import javafx.beans.property.BooleanProperty;
59
import javafx.beans.property.SimpleBooleanProperty;
60
import javafx.beans.value.ObservableBooleanValue;
61
import javafx.beans.value.ObservableValue;
62
import javafx.collections.ListChangeListener.Change;
63
import javafx.collections.ObservableList;
64
import static javafx.event.Event.fireEvent;
65
import javafx.geometry.Pos;
66
import javafx.scene.Node;
67
import javafx.scene.Scene;
68
import javafx.scene.control.Alert;
69
import javafx.scene.control.Alert.AlertType;
70
import javafx.scene.control.Menu;
71
import javafx.scene.control.MenuBar;
72
import javafx.scene.control.SplitPane;
73
import javafx.scene.control.Tab;
74
import javafx.scene.control.TextField;
75
import javafx.scene.control.ToolBar;
76
import javafx.scene.control.TreeView;
77
import javafx.scene.image.Image;
78
import javafx.scene.image.ImageView;
79
import static javafx.scene.input.KeyCode.ESCAPE;
80
import javafx.scene.input.KeyEvent;
81
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
82
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
83
import javafx.scene.layout.BorderPane;
84
import javafx.scene.layout.VBox;
85
import javafx.scene.text.Text;
86
import javafx.stage.Window;
87
import javafx.stage.WindowEvent;
88
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
89
import org.controlsfx.control.StatusBar;
90
import org.fxmisc.richtext.model.TwoDimensional.Position;
91
92
/**
93
 * Main window containing a tab pane in the center for file editors.
94
 *
95
 * @author Karl Tauber and White Magic Software, Ltd.
96
 */
97
public class MainWindow implements Observer {
98
99
  private final Options options = Services.load( Options.class );
100
  private final Snitch snitch = Services.load( Snitch.class );
101
  private final Notifier notifier = Services.load( Notifier.class );
102
103
  private Scene scene;
104
  private MenuBar menuBar;
105
  private StatusBar statusBar;
106
  private Text lineNumberText;
107
  private TextField findTextField;
108
109
  private DefinitionSource definitionSource;
110
  private DefinitionPane definitionPane;
111
  private FileEditorTabPane fileEditorPane;
112
  private HTMLPreviewPane previewPane;
113
114
  /**
115
   * Prevent re-instantiation processing classes.
116
   */
117
  private Map<FileEditorTab, Processor<String>> processors;
118
119
  public MainWindow() {
120
    initLayout();
121
    initFindInput();
122
    initSnitch();
123
    initDefinitionListener();
124
    initTabAddedListener();
125
    initTabChangedListener();
126
    initPreferences();
127
  }
128
129
  /**
130
   * Watch for changes to external files. In particular, this awaits
131
   * modifications to any XSL files associated with XML files being edited. When
132
   * an XSL file is modified (external to the application), the snitch's ears
133
   * perk up and the file is reloaded. This keeps the XSL transformation up to
134
   * date with what's on the file system.
135
   */
136
  private void initSnitch() {
137
    getSnitch().addObserver( this );
138
  }
139
140
  /**
141
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
142
   * presses.
143
   */
144
  private void initFindInput() {
145
    final TextField input = getFindTextField();
146
147
    input.setOnKeyPressed( (KeyEvent event) -> {
148
      switch( event.getCode() ) {
149
        case F3:
150
        case ENTER:
151
          findNext();
152
          break;
153
        case F:
154
          if( !event.isControlDown() ) {
155
            break;
156
          }
157
        case ESCAPE:
158
          getStatusBar().setGraphic( null );
159
          getActiveFileEditor().getEditorPane().requestFocus();
160
          break;
161
      }
162
    } );
163
164
    // Remove when the input field loses focus.
165
    input.focusedProperty().addListener(
166
      (
167
        final ObservableValue<? extends Boolean> focused,
168
        final Boolean oFocus,
169
        final Boolean nFocus) -> {
170
        if( !nFocus ) {
171
          getStatusBar().setGraphic( null );
172
        }
173
      }
174
    );
175
  }
176
177
  /**
178
   * Listen for file editor tab pane to receive an open definition source event.
179
   */
180
  private void initDefinitionListener() {
181
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
182
      (ObservableValue<? extends Path> definitionFile,
183
        final Path oldPath, final Path newPath) -> {
184
        openDefinition( newPath );
185
186
        // Indirectly refresh the resolved map.
187
        setProcessors( null );
188
189
        updateDefinitionPane();
190
191
        try {
192
          getSnitch().ignore( oldPath );
193
          getSnitch().listen( newPath );
194
        } catch( final IOException ex ) {
195
          error( ex );
196
        }
197
198
        // Will create new processors and therefore a new resolved map.
199
        refreshSelectedTab( getActiveFileEditor() );
200
      }
201
    );
202
  }
203
204
  /**
205
   * When tabs are added, hook the various change listeners onto the new tab so
206
   * that the preview pane refreshes as necessary.
207
   */
208
  private void initTabAddedListener() {
209
    final FileEditorTabPane editorPane = getFileEditorPane();
210
211
    // Make sure the text processor kicks off when new files are opened.
212
    final ObservableList<Tab> tabs = editorPane.getTabs();
213
214
    // Update the preview pane on tab changes.
215
    tabs.addListener(
216
      (final Change<? extends Tab> change) -> {
217
        while( change.next() ) {
218
          if( change.wasAdded() ) {
219
            // Multiple tabs can be added simultaneously.
220
            for( final Tab newTab : change.getAddedSubList() ) {
221
              final FileEditorTab tab = (FileEditorTab)newTab;
222
223
              initTextChangeListener( tab );
224
              initCaretParagraphListener( tab );
225
              initVariableNameInjector( tab );
226
//              initSyntaxListener( tab );
227
            }
228
          }
229
        }
230
      }
231
    );
232
  }
233
234
  /**
235
   * Reloads the preferences from the previous load.
236
   */
237
  private void initPreferences() {
238
    restoreDefinitionSource();
239
    getFileEditorPane().restorePreferences();
240
    updateDefinitionPane();
241
  }
242
243
  /**
244
   * Listen for new tab selection events.
245
   */
246
  private void initTabChangedListener() {
247
    final FileEditorTabPane editorPane = getFileEditorPane();
248
249
    // Update the preview pane changing tabs.
250
    editorPane.addTabSelectionListener(
251
      (ObservableValue<? extends Tab> tabPane,
252
        final Tab oldTab, final Tab newTab) -> {
253
254
        // If there was no old tab, then this is a first time load, which
255
        // can be ignored.
256
        if( oldTab != null ) {
257
          if( newTab == null ) {
258
            closeRemainingTab();
259
          }
260
          else {
261
            // Update the preview with the edited text.
262
            refreshSelectedTab( (FileEditorTab)newTab );
263
          }
264
        }
265
      }
266
    );
267
  }
268
269
  private void initTextChangeListener( final FileEditorTab tab ) {
270
    tab.addTextChangeListener(
271
      (ObservableValue<? extends String> editor,
272
        final String oldValue, final String newValue) -> {
273
        refreshSelectedTab( tab );
274
      }
275
    );
276
  }
277
278
  private void initCaretParagraphListener( final FileEditorTab tab ) {
279
    tab.addCaretParagraphListener(
280
      (ObservableValue<? extends Integer> editor,
281
        final Integer oldValue, final Integer newValue) -> {
282
        refreshSelectedTab( tab );
283
      }
284
    );
285
  }
286
287
  private void initVariableNameInjector( final FileEditorTab tab ) {
288
    VariableNameInjector.listen( tab, getDefinitionPane() );
289
  }
290
291
  /**
292
   * Called whenever the preview pane becomes out of sync with the file editor
293
   * tab. This can be called when the text changes, the caret paragraph changes,
294
   * or the file tab changes.
295
   *
296
   * @param tab The file editor tab that has been changed in some fashion.
297
   */
298
  private void refreshSelectedTab( final FileEditorTab tab ) {
299
    if( tab.isFileOpen() ) {
300
      getPreviewPane().setPath( tab.getPath() );
301
302
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
303
      final Position p = tab.getCaretOffset();
304
      getLineNumberText().setText(
305
        get( STATUS_BAR_LINE,
306
          p.getMajor() + 1,
307
          p.getMinor() + 1,
308
          tab.getCaretPosition() + 1
309
        )
310
      );
311
312
      Processor<String> processor = getProcessors().get( tab );
313
314
      if( processor == null ) {
315
        processor = createProcessor( tab );
316
        getProcessors().put( tab, processor );
317
      }
318
319
      try {
320
        getNotifier().clear();
321
        processor.processChain( tab.getEditorText() );
322
      } catch( final Exception ex ) {
323
        error( ex );
324
      }
325
    }
326
  }
327
328
  /**
329
   * Used to find text in the active file editor window.
330
   */
331
  private void find() {
332
    final TextField input = getFindTextField();
333
    getStatusBar().setGraphic( input );
334
    input.requestFocus();
335
  }
336
337
  public void findNext() {
338
    getActiveFileEditor().searchNext( getFindTextField().getText() );
339
  }
340
341
  /**
342
   * Returns the variable map of interpolated definitions.
343
   *
344
   * @return A map to help dereference variables.
345
   */
346
  private Map<String, String> getResolvedMap() {
347
    return getDefinitionSource().getResolvedMap();
348
  }
349
350
  /**
351
   * Returns the root node for the hierarchical definition source.
352
   *
353
   * @return Data to display in the definition pane.
354
   */
355
  private TreeView<String> getTreeView() {
356
    try {
357
      return getDefinitionSource().asTreeView();
358
    } catch( Exception e ) {
359
      error( e );
360
    }
361
362
    // Slightly redundant as getDefinitionSource() might have returned an
363
    // empty definition source.
364
    return (new EmptyDefinitionSource()).asTreeView();
365
  }
366
367
  /**
368
   * Called when a definition source is opened.
369
   *
370
   * @param path Path to the definition source that was opened.
371
   */
372
  private void openDefinition( final Path path ) {
373
    try {
374
      final DefinitionSource ds = createDefinitionSource( path.toString() );
375
      setDefinitionSource( ds );
376
      storeDefinitionSource();
377
      updateDefinitionPane();
378
    } catch( final Exception e ) {
379
      error( e );
380
    }
381
  }
382
383
  private void updateDefinitionPane() {
384
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
385
  }
386
387
  private void restoreDefinitionSource() {
388
    final Preferences preferences = getPreferences();
389
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
390
391
    // If there's no definition source set, don't try to load it.
392
    if( source != null ) {
393
      setDefinitionSource( createDefinitionSource( source ) );
394
    }
395
  }
396
397
  private void storeDefinitionSource() {
398
    final Preferences preferences = getPreferences();
399
    final DefinitionSource ds = getDefinitionSource();
400
401
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
402
  }
403
404
  /**
405
   * Called when the last open tab is closed to clear the preview pane.
406
   */
407
  private void closeRemainingTab() {
408
    getPreviewPane().clear();
409
  }
410
411
  /**
412
   * Called when an exception occurs that warrants the user's attention.
413
   *
414
   * @param e The exception with a message that the user should know about.
415
   */
416
  private void error( final Exception e ) {
417
    getNotifier().notify( e );
418
  }
419
420
  //---- File actions -------------------------------------------------------
421
  /**
422
   * Called when an observable instance has changed. This is called by both the
423
   * snitch service and the notify service. The snitch service can be called for
424
   * different file types, including definition sources.
425
   *
426
   * @param observable The observed instance.
427
   * @param value The noteworthy item.
428
   */
429
  @Override
430
  public void update( final Observable observable, final Object value ) {
431
    if( value != null ) {
432
      if( observable instanceof Snitch && value instanceof Path ) {
433
        final Path path = (Path)value;
434
        final FileTypePredicate predicate
435
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
436
437
        // Reload definitions.
438
        if( predicate.test( path.toFile() ) ) {
439
          updateDefinitionSource( path );
440
        }
441
442
        updateSelectedTab();
443
      }
444
      else if( observable instanceof Notifier && value instanceof String ) {
445
        updateStatusBar( (String)value );
446
      }
447
    }
448
  }
449
450
  /**
451
   * Updates the status bar to show the given message.
452
   *
453
   * @param s The message to show in the status bar.
454
   */
455
  private void updateStatusBar( final String s ) {
456
    Platform.runLater(
457
      () -> {
458
        final int index = s.indexOf( '\n' );
459
        final String message = s.substring( 0, index > 0 ? index : s.length() );
460
461
        getStatusBar().setText( message );
462
      }
463
    );
464
  }
465
466
  /**
467
   * Called when a file has been modified.
468
   *
469
   * @param file Path to the modified file.
470
   */
471
  private void updateSelectedTab() {
472
    Platform.runLater(
473
      () -> {
474
        // Brute-force XSLT file reload by re-instantiating all processors.
475
        resetProcessors();
476
        refreshSelectedTab( getActiveFileEditor() );
477
      }
478
    );
479
  }
480
481
  /**
482
   * Reloads the definition source from the given path.
483
   *
484
   * @param path The path containing new definition information.
485
   */
486
  private void updateDefinitionSource( final Path path ) {
487
    Platform.runLater(
488
      () -> {
489
        openDefinition( path );
490
      }
491
    );
492
  }
493
494
  /**
495
   * After resetting the processors, they will refresh anew to be up-to-date
496
   * with the files (text and definition) currently loaded into the editor.
497
   */
498
  private void resetProcessors() {
499
    getProcessors().clear();
500
  }
501
502
  //---- File actions -------------------------------------------------------
503
  private void fileNew() {
504
    getFileEditorPane().newEditor();
505
  }
506
507
  private void fileOpen() {
508
    getFileEditorPane().openFileDialog();
509
  }
510
511
  private void fileClose() {
512
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
513
  }
514
515
  private void fileCloseAll() {
516
    getFileEditorPane().closeAllEditors();
517
  }
518
519
  private void fileSave() {
520
    getFileEditorPane().saveEditor( getActiveFileEditor() );
521
  }
522
523
  private void fileSaveAll() {
524
    getFileEditorPane().saveAllEditors();
525
  }
526
527
  private void fileExit() {
528
    final Window window = getWindow();
529
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
530
  }
531
532
  //---- Help actions -------------------------------------------------------
533
  private void helpAbout() {
534
    Alert alert = new Alert( AlertType.INFORMATION );
535
    alert.setTitle( get( "Dialog.about.title" ) );
536
    alert.setHeaderText( get( "Dialog.about.header" ) );
537
    alert.setContentText( get( "Dialog.about.content" ) );
538
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
539
    alert.initOwner( getWindow() );
540
541
    alert.showAndWait();
542
  }
543
544
  //---- Convenience accessors ----------------------------------------------
545
  private float getFloat( final String key, final float defaultValue ) {
546
    return getPreferences().getFloat( key, defaultValue );
547
  }
548
549
  private Preferences getPreferences() {
550
    return getOptions().getState();
551
  }
552
553
  private TextField createFindTextField() {
554
    return new TextField();
555
  }
556
557
  protected Scene getScene() {
558
    if( this.scene == null ) {
559
      this.scene = createScene();
560
    }
561
562
    return this.scene;
563
  }
564
565
  public Window getWindow() {
566
    return getScene().getWindow();
567
  }
568
569
  private MarkdownEditorPane getActiveEditor() {
570
    final EditorPane pane = getActiveFileEditor().getEditorPane();
571
572
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
573
  }
574
575
  private FileEditorTab getActiveFileEditor() {
576
    return getFileEditorPane().getActiveFileEditor();
577
  }
578
579
  //---- Member accessors ---------------------------------------------------
580
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
581
    this.processors = map;
582
  }
583
584
  private Map<FileEditorTab, Processor<String>> getProcessors() {
585
    if( this.processors == null ) {
586
      setProcessors( new HashMap<>() );
587
    }
588
589
    return this.processors;
590
  }
591
592
  private FileEditorTabPane getFileEditorPane() {
593
    if( this.fileEditorPane == null ) {
594
      this.fileEditorPane = createFileEditorPane();
595
    }
596
597
    return this.fileEditorPane;
598
  }
599
600
  private HTMLPreviewPane getPreviewPane() {
601
    if( this.previewPane == null ) {
602
      this.previewPane = createPreviewPane();
603
    }
604
605
    return this.previewPane;
606
  }
607
608
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
609
    this.definitionSource = definitionSource;
610
  }
611
612
  private DefinitionSource getDefinitionSource() {
613
    if( this.definitionSource == null ) {
614
      this.definitionSource = new EmptyDefinitionSource();
615
    }
616
617
    return this.definitionSource;
618
  }
619
620
  private DefinitionPane getDefinitionPane() {
621
    if( this.definitionPane == null ) {
622
      this.definitionPane = createDefinitionPane();
623
    }
624
625
    return this.definitionPane;
626
  }
627
628
  private Options getOptions() {
629
    return this.options;
630
  }
631
632
  private Snitch getSnitch() {
633
    return this.snitch;
634
  }
635
636
  private Notifier getNotifier() {
637
    return this.notifier;
638
  }
639
640
  public void setMenuBar( final MenuBar menuBar ) {
641
    this.menuBar = menuBar;
642
  }
643
644
  public MenuBar getMenuBar() {
645
    return this.menuBar;
646
  }
647
648
  private Text getLineNumberText() {
649
    if( this.lineNumberText == null ) {
650
      this.lineNumberText = createLineNumberText();
651
    }
652
653
    return this.lineNumberText;
654
  }
655
656
  private synchronized StatusBar getStatusBar() {
657
    if( this.statusBar == null ) {
658
      this.statusBar = createStatusBar();
659
    }
660
661
    return this.statusBar;
662
  }
663
664
  private TextField getFindTextField() {
665
    if( this.findTextField == null ) {
666
      this.findTextField = createFindTextField();
667
    }
668
669
    return this.findTextField;
670
  }
671
672
  //---- Member creators ----------------------------------------------------
673
  /**
674
   * Factory to create processors that are suited to different file types.
675
   *
676
   * @param tab The tab that is subjected to processing.
677
   *
678
   * @return A processor suited to the file type specified by the tab's path.
679
   */
680
  private Processor<String> createProcessor( final FileEditorTab tab ) {
681
    return createProcessorFactory().createProcessor( tab );
682
  }
683
684
  private ProcessorFactory createProcessorFactory() {
685
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
686
  }
687
688
  private DefinitionSource createDefinitionSource( final String path ) {
689
    final DefinitionSource ds
690
      = createDefinitionFactory().createDefinitionSource( path );
691
692
    if( ds instanceof FileDefinitionSource ) {
693
      try {
694
        getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
695
      } catch( final IOException ex ) {
696
        error( ex );
697
      }
698
    }
699
700
    return ds;
701
  }
702
703
  /**
704
   * Create an editor pane to hold file editor tabs.
705
   *
706
   * @return A new instance, never null.
707
   */
708
  private FileEditorTabPane createFileEditorPane() {
709
    return new FileEditorTabPane();
710
  }
711
712
  private HTMLPreviewPane createPreviewPane() {
713
    return new HTMLPreviewPane();
714
  }
715
716
  private DefinitionPane createDefinitionPane() {
717
    return new DefinitionPane( getTreeView() );
718
  }
719
720
  private DefinitionFactory createDefinitionFactory() {
721
    return new DefinitionFactory();
722
  }
723
724
  private StatusBar createStatusBar() {
725
    return new StatusBar();
726
  }
727
728
  private Scene createScene() {
729
    final SplitPane splitPane = new SplitPane(
730
      getDefinitionPane().getNode(),
731
      getFileEditorPane().getNode(),
732
      getPreviewPane().getNode() );
733
734
    splitPane.setDividerPositions(
735
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
736
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
737
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
738
739
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
740
    final BorderPane borderPane = new BorderPane();
741
    borderPane.setPrefSize( 1024, 800 );
742
    borderPane.setTop( createMenuBar() );
743
    borderPane.setBottom( getStatusBar() );
744
    borderPane.setCenter( splitPane );
745
746
    final VBox box = new VBox();
747
    box.setAlignment( Pos.BASELINE_CENTER );
748
    box.getChildren().add( getLineNumberText() );
749
    getStatusBar().getRightItems().add( box );
750
751
    return new Scene( borderPane );
752
  }
753
754
  private Text createLineNumberText() {
755
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
756
  }
757
758
  private Node createMenuBar() {
759
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
760
761
    // File actions
762
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
763
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
764
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
765
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
766
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
767
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
768
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
769
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
770
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
771
772
    // Edit actions
773
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
774
      e -> getActiveEditor().undo(),
775
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
776
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
777
      e -> getActiveEditor().redo(),
778
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
779
    Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
780
      e -> find(),
781
      activeFileEditorIsNull );
782
    Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
783
      e -> getActiveEditor().replace(),
784
      activeFileEditorIsNull );
785
    Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
786
      e -> findNext(),
787
      activeFileEditorIsNull );
788
    Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
789
      e -> getActiveEditor().findPrevious(),
790
      activeFileEditorIsNull );
791
792
    // Insert actions
793
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
794
      e -> getActiveEditor().surroundSelection( "**", "**" ),
795
      activeFileEditorIsNull );
796
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
797
      e -> getActiveEditor().surroundSelection( "*", "*" ),
798
      activeFileEditorIsNull );
799
    Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
800
      e -> getActiveEditor().surroundSelection( "^", "^" ),
801
      activeFileEditorIsNull );
802
    Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
803
      e -> getActiveEditor().surroundSelection( "~", "~" ),
804
      activeFileEditorIsNull );
805
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
806
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
807
      activeFileEditorIsNull );
808
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
809
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
810
      activeFileEditorIsNull );
811
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
812
      e -> getActiveEditor().surroundSelection( "`", "`" ),
813
      activeFileEditorIsNull );
814
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
815
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
816
      activeFileEditorIsNull );
817
818
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
819
      e -> getActiveEditor().insertLink(),
820
      activeFileEditorIsNull );
821
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
822
      e -> getActiveEditor().insertImage(),
823
      activeFileEditorIsNull );
824
825
    final Action[] headers = new Action[ 6 ];
826
827
    // Insert header actions (H1 ... H6)
828
    for( int i = 1; i <= 6; i++ ) {
829
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
830
      final String markup = String.format( "%n%n%s ", hashes );
831
      final String text = get( "Main.menu.insert.header_" + i );
832
      final String accelerator = "Shortcut+" + i;
833
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
834
835
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
836
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
837
        activeFileEditorIsNull );
838
    }
839
840
    Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
841
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
842
      activeFileEditorIsNull );
843
    Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
844
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
845
      activeFileEditorIsNull );
846
    Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
847
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
848
      activeFileEditorIsNull );
849
850
    // Help actions
851
    Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
852
853
    //---- MenuBar ----
854
    Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
855
      fileNewAction,
856
      fileOpenAction,
857
      null,
858
      fileCloseAction,
859
      fileCloseAllAction,
860
      null,
861
      fileSaveAction,
862
      fileSaveAllAction,
863
      null,
864
      fileExitAction );
865
866
    Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
867
      editUndoAction,
868
      editRedoAction,
869
      editFindAction,
870
      editReplaceAction,
871
      editFindNextAction,
872
      editFindPreviousAction );
873
874
    Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
875
      insertBoldAction,
876
      insertItalicAction,
877
      insertSuperscriptAction,
878
      insertSubscriptAction,
879
      insertStrikethroughAction,
880
      insertBlockquoteAction,
881
      insertCodeAction,
882
      insertFencedCodeBlockAction,
883
      null,
884
      insertLinkAction,
885
      insertImageAction,
886
      null,
887
      headers[ 0 ],
888
      headers[ 1 ],
889
      headers[ 2 ],
890
      headers[ 3 ],
891
      headers[ 4 ],
892
      headers[ 5 ],
893
      null,
894
      insertUnorderedListAction,
895
      insertOrderedListAction,
896
      insertHorizontalRuleAction );
897
898
    Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
899
      helpAboutAction );
900
901
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
902
903
    //---- ToolBar ----
904
    ToolBar toolBar = ActionUtils.createToolBar(
905
      fileNewAction,
906
      fileOpenAction,
907
      fileSaveAction,
908
      null,
909
      editUndoAction,
910
      editRedoAction,
911
      null,
912
      insertBoldAction,
913
      insertItalicAction,
914
      insertSuperscriptAction,
915
      insertSubscriptAction,
916
      insertBlockquoteAction,
917
      insertCodeAction,
918
      insertFencedCodeBlockAction,
919
      null,
920
      insertLinkAction,
921
      insertImageAction,
922
      null,
923
      headers[ 0 ],
924
      null,
925
      insertUnorderedListAction,
926
      insertOrderedListAction );
927
928
    return new VBox( menuBar, toolBar );
929
  }
930
931
  /**
932
   * Creates a boolean property that is bound to another boolean value of the
933
   * active editor.
934
   */
935
  private BooleanProperty createActiveBooleanProperty(
936
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
937
938
    final BooleanProperty b = new SimpleBooleanProperty();
939
    final FileEditorTab tab = getActiveFileEditor();
940
941
    if( tab != null ) {
942
      b.bind( func.apply( tab ) );
943
    }
944
945
    getFileEditorPane().activeFileEditorProperty().addListener(
946
      (observable, oldFileEditor, newFileEditor) -> {
947
        b.unbind();
948
949
        if( newFileEditor != null ) {
950
          b.bind( func.apply( newFileEditor ) );
951
        }
952
        else {
953
          b.set( false );
954
        }
955
      }
956
    );
957
958
    return b;
959
  }
960
961
  private void initLayout() {
962
    final Scene appScene = getScene();
963
964
    appScene.getStylesheets().add( STYLESHEET_SCENE );
965
//    appScene.getStylesheets().add( STYLESHEET_XML );
966
967
    appScene.windowProperty().addListener(
968
      (observable, oldWindow, newWindow) -> {
969
        newWindow.setOnCloseRequest( e -> {
970
          if( !getFileEditorPane().closeAllEditors() ) {
971
            e.consume();
972
          }
973
        } );
974
975
        // Workaround JavaFX bug: deselect menubar if window loses focus.
976
        newWindow.focusedProperty().addListener(
977
          (obs, oldFocused, newFocused) -> {
978
            if( !newFocused ) {
979
              // Send an ESC key event to the menubar
980
              this.menuBar.fireEvent(
981
                new KeyEvent(
982
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
983
                  false, false, false, false ) );
984
            }
985
          }
986
        );
987
      }
988
    );
989
  }
990
991
//  private void initSyntaxListener( final FileEditorTab tab ) {
992
//    tab.addTextChangeListener(
993
//      (ObservableValue<? extends String> observable,
994
//        final String oText, final String nText) -> {
995
//        tab.getEditorPane().getEditor().setStyleSpans( 0, highlight( nText ) );
996
//      }
997
//    );
998
//  }
999
//
1000
//  private static final Pattern XML_TAG = Pattern.compile( "(?<ELEMENT>(</?\\h*)(\\w+)([^<>]*)(\\h*/?>))"
1001
//    + "|(?<COMMENT><!--[^<>]+-->)" );
1002
//
1003
//  private static final Pattern ATTRIBUTES = Pattern.compile( "(\\w+\\h*)(=)(\\h*\"[^\"]+\")" );
1004
//
1005
//  private static final int GROUP_OPEN_BRACKET = 2;
1006
//  private static final int GROUP_ELEMENT_NAME = 3;
1007
//  private static final int GROUP_ATTRIBUTES_SECTION = 4;
1008
//  private static final int GROUP_CLOSE_BRACKET = 5;
1009
//  private static final int GROUP_ATTRIBUTE_NAME = 1;
1010
//  private static final int GROUP_EQUAL_SYMBOL = 2;
1011
//  private static final int GROUP_ATTRIBUTE_VALUE = 3;
1012
//
1013
//  private static StyleSpans<Collection<String>> highlight( final String text ) {
1014
//    final Matcher matcher = XML_TAG.matcher( text );
1015
//    int lastKwEnd = 0;
1016
//    final StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();
1017
//
1018
//    while( matcher.find() ) {
1019
//      spansBuilder.add( Collections.emptyList(), matcher.start() - lastKwEnd );
1020
//
1021
//      if( matcher.group( "COMMENT" ) != null ) {
1022
//        spansBuilder.add( Collections.singleton( "comment" ), matcher.end() - matcher.start() );
1023
//      }
1024
//      else if( matcher.group( "ELEMENT" ) != null ) {
1025
//        String attributesText = matcher.group( GROUP_ATTRIBUTES_SECTION );
1026
//
1027
//        spansBuilder.add( Collections.singleton( "tagmark" ), matcher.end( GROUP_OPEN_BRACKET ) - matcher.start( GROUP_OPEN_BRACKET ) );
1028
//        spansBuilder.add( Collections.singleton( "anytag" ), matcher.end( GROUP_ELEMENT_NAME ) - matcher.end( GROUP_OPEN_BRACKET ) );
1029
//
1030
//        if( !attributesText.isEmpty() ) {
1031
//          lastKwEnd = 0;
1032
//
1033
//          final Matcher amatcher = ATTRIBUTES.matcher( attributesText );
1034
//
1035
//          while( amatcher.find() ) {
1036
//            spansBuilder.add( Collections.emptyList(), amatcher.start() - lastKwEnd );
1037
//            spansBuilder.add( Collections.singleton( "attribute" ), amatcher.end( GROUP_ATTRIBUTE_NAME ) - amatcher.start( GROUP_ATTRIBUTE_NAME ) );
1038
//            spansBuilder.add( Collections.singleton( "tagmark" ), amatcher.end( GROUP_EQUAL_SYMBOL ) - amatcher.end( GROUP_ATTRIBUTE_NAME ) );
1039
//            spansBuilder.add( Collections.singleton( "avalue" ), amatcher.end( GROUP_ATTRIBUTE_VALUE ) - amatcher.end( GROUP_EQUAL_SYMBOL ) );
1040
//            lastKwEnd = amatcher.end();
1041
//          }
1042
//
1043
//          if( attributesText.length() > lastKwEnd ) {
1044
//            spansBuilder.add( Collections.emptyList(), attributesText.length() - lastKwEnd );
1045
//          }
1046
//        }
1047
//
1048
//        lastKwEnd = matcher.end( GROUP_ATTRIBUTES_SECTION );
1049
//        spansBuilder.add( Collections.singleton( "tagmark" ), matcher.end( GROUP_CLOSE_BRACKET ) - lastKwEnd );
1050
//      }
1051
//
1052
//      lastKwEnd = matcher.end();
1053
//    }
1054
//
1055
//    spansBuilder.add( Collections.emptyList(), text.length() - lastKwEnd );
1056
//    return spansBuilder.create();
1057
//  }
41
import com.scrivenvar.service.Settings;
42
import com.scrivenvar.service.Snitch;
43
import com.scrivenvar.service.events.Notifier;
44
import com.scrivenvar.util.Action;
45
import com.scrivenvar.util.ActionUtils;
46
import static com.scrivenvar.util.StageState.*;
47
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
48
import java.io.IOException;
49
import java.nio.file.Path;
50
import java.util.HashMap;
51
import java.util.Map;
52
import java.util.Observable;
53
import java.util.Observer;
54
import java.util.Optional;
55
import java.util.function.Function;
56
import java.util.prefs.Preferences;
57
import javafx.application.Platform;
58
import javafx.beans.binding.Bindings;
59
import javafx.beans.binding.BooleanBinding;
60
import javafx.beans.property.BooleanProperty;
61
import javafx.beans.property.SimpleBooleanProperty;
62
import javafx.beans.value.ObservableBooleanValue;
63
import javafx.beans.value.ObservableValue;
64
import javafx.collections.ListChangeListener.Change;
65
import javafx.collections.ObservableList;
66
import static javafx.event.Event.fireEvent;
67
import javafx.geometry.Insets;
68
import javafx.geometry.Pos;
69
import javafx.scene.Node;
70
import javafx.scene.Scene;
71
import javafx.scene.control.Alert;
72
import javafx.scene.control.Alert.AlertType;
73
import javafx.scene.control.ButtonBar.ButtonData;
74
import javafx.scene.control.ButtonType;
75
import javafx.scene.control.Dialog;
76
import javafx.scene.control.Menu;
77
import javafx.scene.control.MenuBar;
78
import javafx.scene.control.SplitPane;
79
import javafx.scene.control.Tab;
80
import javafx.scene.control.TextArea;
81
import javafx.scene.control.TextField;
82
import javafx.scene.control.ToolBar;
83
import javafx.scene.control.TreeView;
84
import javafx.scene.image.Image;
85
import javafx.scene.image.ImageView;
86
import static javafx.scene.input.KeyCode.ESCAPE;
87
import javafx.scene.input.KeyEvent;
88
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
89
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
90
import javafx.scene.layout.BorderPane;
91
import javafx.scene.layout.GridPane;
92
import javafx.scene.layout.VBox;
93
import javafx.scene.text.Text;
94
import javafx.stage.Window;
95
import javafx.stage.WindowEvent;
96
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
97
import org.controlsfx.control.StatusBar;
98
import org.fxmisc.richtext.model.TwoDimensional.Position;
99
100
/**
101
 * Main window containing a tab pane in the center for file editors.
102
 *
103
 * @author Karl Tauber and White Magic Software, Ltd.
104
 */
105
public class MainWindow implements Observer {
106
107
  private final Options options = Services.load( Options.class );
108
  private final Settings settings = Services.load( Settings.class );
109
  private final Snitch snitch = Services.load( Snitch.class );
110
  private final Notifier notifier = Services.load( Notifier.class );
111
112
  private Scene scene;
113
  private MenuBar menuBar;
114
  private StatusBar statusBar;
115
  private Text lineNumberText;
116
  private TextField findTextField;
117
118
  private DefinitionSource definitionSource;
119
  private DefinitionPane definitionPane;
120
  private FileEditorTabPane fileEditorPane;
121
  private HTMLPreviewPane previewPane;
122
123
  /**
124
   * Prevent re-instantiation processing classes.
125
   */
126
  private Map<FileEditorTab, Processor<String>> processors;
127
128
  public MainWindow() {
129
    initLayout();
130
    initFindInput();
131
    initSnitch();
132
    initDefinitionListener();
133
    initTabAddedListener();
134
    initTabChangedListener();
135
    initPreferences();
136
  }
137
138
  public Settings getSettings() {
139
    return settings;
140
  }
141
142
  /**
143
   * Watch for changes to external files. In particular, this awaits
144
   * modifications to any XSL files associated with XML files being edited. When
145
   * an XSL file is modified (external to the application), the snitch's ears
146
   * perk up and the file is reloaded. This keeps the XSL transformation up to
147
   * date with what's on the file system.
148
   */
149
  private void initSnitch() {
150
    getSnitch().addObserver( this );
151
  }
152
153
  /**
154
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
155
   * presses.
156
   */
157
  private void initFindInput() {
158
    final TextField input = getFindTextField();
159
160
    input.setOnKeyPressed( (KeyEvent event) -> {
161
      switch( event.getCode() ) {
162
        case F3:
163
        case ENTER:
164
          findNext();
165
          break;
166
        case F:
167
          if( !event.isControlDown() ) {
168
            break;
169
          }
170
        case ESCAPE:
171
          getStatusBar().setGraphic( null );
172
          getActiveFileEditor().getEditorPane().requestFocus();
173
          break;
174
      }
175
    } );
176
177
    // Remove when the input field loses focus.
178
    input.focusedProperty().addListener(
179
      (
180
        final ObservableValue<? extends Boolean> focused,
181
        final Boolean oFocus,
182
        final Boolean nFocus) -> {
183
        if( !nFocus ) {
184
          getStatusBar().setGraphic( null );
185
        }
186
      }
187
    );
188
  }
189
190
  /**
191
   * Listen for file editor tab pane to receive an open definition source event.
192
   */
193
  private void initDefinitionListener() {
194
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
195
      (ObservableValue<? extends Path> definitionFile,
196
        final Path oldPath, final Path newPath) -> {
197
        openDefinition( newPath );
198
199
        // Indirectly refresh the resolved map.
200
        setProcessors( null );
201
202
        updateDefinitionPane();
203
204
        try {
205
          getSnitch().ignore( oldPath );
206
          getSnitch().listen( newPath );
207
        } catch( final IOException ex ) {
208
          error( ex );
209
        }
210
211
        // Will create new processors and therefore a new resolved map.
212
        refreshSelectedTab( getActiveFileEditor() );
213
      }
214
    );
215
  }
216
217
  /**
218
   * When tabs are added, hook the various change listeners onto the new tab so
219
   * that the preview pane refreshes as necessary.
220
   */
221
  private void initTabAddedListener() {
222
    final FileEditorTabPane editorPane = getFileEditorPane();
223
224
    // Make sure the text processor kicks off when new files are opened.
225
    final ObservableList<Tab> tabs = editorPane.getTabs();
226
227
    // Update the preview pane on tab changes.
228
    tabs.addListener(
229
      (final Change<? extends Tab> change) -> {
230
        while( change.next() ) {
231
          if( change.wasAdded() ) {
232
            // Multiple tabs can be added simultaneously.
233
            for( final Tab newTab : change.getAddedSubList() ) {
234
              final FileEditorTab tab = (FileEditorTab)newTab;
235
236
              initTextChangeListener( tab );
237
              initCaretParagraphListener( tab );
238
              initVariableNameInjector( tab );
239
//              initSyntaxListener( tab );
240
            }
241
          }
242
        }
243
      }
244
    );
245
  }
246
247
  /**
248
   * Reloads the preferences from the previous load.
249
   */
250
  private void initPreferences() {
251
    restoreDefinitionSource();
252
    getFileEditorPane().restorePreferences();
253
    updateDefinitionPane();
254
  }
255
256
  /**
257
   * Listen for new tab selection events.
258
   */
259
  private void initTabChangedListener() {
260
    final FileEditorTabPane editorPane = getFileEditorPane();
261
262
    // Update the preview pane changing tabs.
263
    editorPane.addTabSelectionListener(
264
      (ObservableValue<? extends Tab> tabPane,
265
        final Tab oldTab, final Tab newTab) -> {
266
267
        // If there was no old tab, then this is a first time load, which
268
        // can be ignored.
269
        if( oldTab != null ) {
270
          if( newTab == null ) {
271
            closeRemainingTab();
272
          }
273
          else {
274
            // Update the preview with the edited text.
275
            refreshSelectedTab( (FileEditorTab)newTab );
276
          }
277
        }
278
      }
279
    );
280
  }
281
282
  private void initTextChangeListener( final FileEditorTab tab ) {
283
    tab.addTextChangeListener(
284
      (ObservableValue<? extends String> editor,
285
        final String oldValue, final String newValue) -> {
286
        refreshSelectedTab( tab );
287
      }
288
    );
289
  }
290
291
  private void initCaretParagraphListener( final FileEditorTab tab ) {
292
    tab.addCaretParagraphListener(
293
      (ObservableValue<? extends Integer> editor,
294
        final Integer oldValue, final Integer newValue) -> {
295
        refreshSelectedTab( tab );
296
      }
297
    );
298
  }
299
300
  private void initVariableNameInjector( final FileEditorTab tab ) {
301
    VariableNameInjector.listen( tab, getDefinitionPane() );
302
  }
303
304
  /**
305
   * Called whenever the preview pane becomes out of sync with the file editor
306
   * tab. This can be called when the text changes, the caret paragraph changes,
307
   * or the file tab changes.
308
   *
309
   * @param tab The file editor tab that has been changed in some fashion.
310
   */
311
  private void refreshSelectedTab( final FileEditorTab tab ) {
312
    if( tab.isFileOpen() ) {
313
      getPreviewPane().setPath( tab.getPath() );
314
315
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
316
      final Position p = tab.getCaretOffset();
317
      getLineNumberText().setText(
318
        get( STATUS_BAR_LINE,
319
          p.getMajor() + 1,
320
          p.getMinor() + 1,
321
          tab.getCaretPosition() + 1
322
        )
323
      );
324
325
      Processor<String> processor = getProcessors().get( tab );
326
327
      if( processor == null ) {
328
        processor = createProcessor( tab );
329
        getProcessors().put( tab, processor );
330
      }
331
332
      try {
333
        getNotifier().clear();
334
        processor.processChain( tab.getEditorText() );
335
      } catch( final Exception ex ) {
336
        error( ex );
337
      }
338
    }
339
  }
340
341
  /**
342
   * Used to find text in the active file editor window.
343
   */
344
  private void find() {
345
    final TextField input = getFindTextField();
346
    getStatusBar().setGraphic( input );
347
    input.requestFocus();
348
  }
349
350
  public void findNext() {
351
    getActiveFileEditor().searchNext( getFindTextField().getText() );
352
  }
353
354
  /**
355
   * Returns the variable map of interpolated definitions.
356
   *
357
   * @return A map to help dereference variables.
358
   */
359
  private Map<String, String> getResolvedMap() {
360
    return getDefinitionSource().getResolvedMap();
361
  }
362
363
  /**
364
   * Returns the root node for the hierarchical definition source.
365
   *
366
   * @return Data to display in the definition pane.
367
   */
368
  private TreeView<String> getTreeView() {
369
    try {
370
      return getDefinitionSource().asTreeView();
371
    } catch( Exception e ) {
372
      error( e );
373
    }
374
375
    // Slightly redundant as getDefinitionSource() might have returned an
376
    // empty definition source.
377
    return (new EmptyDefinitionSource()).asTreeView();
378
  }
379
380
  /**
381
   * Called when a definition source is opened.
382
   *
383
   * @param path Path to the definition source that was opened.
384
   */
385
  private void openDefinition( final Path path ) {
386
    try {
387
      final DefinitionSource ds = createDefinitionSource( path.toString() );
388
      setDefinitionSource( ds );
389
      storeDefinitionSource();
390
      updateDefinitionPane();
391
    } catch( final Exception e ) {
392
      error( e );
393
    }
394
  }
395
396
  private void updateDefinitionPane() {
397
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
398
  }
399
400
  private void restoreDefinitionSource() {
401
    final Preferences preferences = getPreferences();
402
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
403
404
    // If there's no definition source set, don't try to load it.
405
    if( source != null ) {
406
      setDefinitionSource( createDefinitionSource( source ) );
407
    }
408
  }
409
410
  private void storeDefinitionSource() {
411
    final Preferences preferences = getPreferences();
412
    final DefinitionSource ds = getDefinitionSource();
413
414
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
415
  }
416
417
  /**
418
   * Called when the last open tab is closed to clear the preview pane.
419
   */
420
  private void closeRemainingTab() {
421
    getPreviewPane().clear();
422
  }
423
424
  /**
425
   * Called when an exception occurs that warrants the user's attention.
426
   *
427
   * @param e The exception with a message that the user should know about.
428
   */
429
  private void error( final Exception e ) {
430
    getNotifier().notify( e );
431
  }
432
433
  //---- File actions -------------------------------------------------------
434
  /**
435
   * Called when an observable instance has changed. This is called by both the
436
   * snitch service and the notify service. The snitch service can be called for
437
   * different file types, including definition sources.
438
   *
439
   * @param observable The observed instance.
440
   * @param value The noteworthy item.
441
   */
442
  @Override
443
  public void update( final Observable observable, final Object value ) {
444
    if( value != null ) {
445
      if( observable instanceof Snitch && value instanceof Path ) {
446
        final Path path = (Path)value;
447
        final FileTypePredicate predicate
448
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
449
450
        // Reload definitions.
451
        if( predicate.test( path.toFile() ) ) {
452
          updateDefinitionSource( path );
453
        }
454
455
        updateSelectedTab();
456
      }
457
      else if( observable instanceof Notifier && value instanceof String ) {
458
        updateStatusBar( (String)value );
459
      }
460
    }
461
  }
462
463
  /**
464
   * Updates the status bar to show the given message.
465
   *
466
   * @param s The message to show in the status bar.
467
   */
468
  private void updateStatusBar( final String s ) {
469
    Platform.runLater(
470
      () -> {
471
        final int index = s.indexOf( '\n' );
472
        final String message = s.substring( 0, index > 0 ? index : s.length() );
473
474
        getStatusBar().setText( message );
475
      }
476
    );
477
  }
478
479
  /**
480
   * Called when a file has been modified.
481
   *
482
   * @param file Path to the modified file.
483
   */
484
  private void updateSelectedTab() {
485
    Platform.runLater(
486
      () -> {
487
        // Brute-force XSLT file reload by re-instantiating all processors.
488
        resetProcessors();
489
        refreshSelectedTab( getActiveFileEditor() );
490
      }
491
    );
492
  }
493
494
  /**
495
   * Reloads the definition source from the given path.
496
   *
497
   * @param path The path containing new definition information.
498
   */
499
  private void updateDefinitionSource( final Path path ) {
500
    Platform.runLater(
501
      () -> {
502
        openDefinition( path );
503
      }
504
    );
505
  }
506
507
  /**
508
   * After resetting the processors, they will refresh anew to be up-to-date
509
   * with the files (text and definition) currently loaded into the editor.
510
   */
511
  private void resetProcessors() {
512
    getProcessors().clear();
513
  }
514
515
  //---- File actions -------------------------------------------------------
516
  private void fileNew() {
517
    getFileEditorPane().newEditor();
518
  }
519
520
  private void fileOpen() {
521
    getFileEditorPane().openFileDialog();
522
  }
523
524
  private void fileClose() {
525
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
526
  }
527
528
  private void fileCloseAll() {
529
    getFileEditorPane().closeAllEditors();
530
  }
531
532
  private void fileSave() {
533
    getFileEditorPane().saveEditor( getActiveFileEditor() );
534
  }
535
536
  private void fileSaveAll() {
537
    getFileEditorPane().saveAllEditors();
538
  }
539
540
  private void fileExit() {
541
    final Window window = getWindow();
542
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
543
  }
544
545
  //---- Help actions -------------------------------------------------------
546
  private void helpAbout() {
547
    Alert alert = new Alert( AlertType.INFORMATION );
548
    alert.setTitle( get( "Dialog.about.title" ) );
549
    alert.setHeaderText( get( "Dialog.about.header" ) );
550
    alert.setContentText( get( "Dialog.about.content" ) );
551
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
552
    alert.initOwner( getWindow() );
553
554
    alert.showAndWait();
555
  }
556
557
  //---- Convenience accessors ----------------------------------------------
558
  private float getFloat( final String key, final float defaultValue ) {
559
    return getPreferences().getFloat( key, defaultValue );
560
  }
561
562
  private Preferences getPreferences() {
563
    return getOptions().getState();
564
  }
565
566
  private TextField createFindTextField() {
567
    return new TextField();
568
  }
569
570
  private void toolsScript() {
571
    try {
572
      // Create a custom dialog.
573
      Dialog<String> dialog = new Dialog<>();
574
      dialog.setTitle( "R Startup Script" );
575
576
      final String script = getSettings().loadRStartupScript();
577
578
      final ButtonType saveButton = new ButtonType( "Save", ButtonData.OK_DONE );
579
      dialog.getDialogPane().getButtonTypes().addAll( saveButton, ButtonType.CANCEL );
580
581
      GridPane grid = new GridPane();
582
      grid.setHgap( 10 );
583
      grid.setVgap( 10 );
584
      grid.setPadding( new Insets( 20, 100, 10, 10 ) );
585
586
      final TextArea textArea = new TextArea( script );
587
      textArea.setEditable( true );
588
      textArea.setWrapText( true );
589
590
      grid.add( textArea, 0, 0 );
591
      dialog.getDialogPane().setContent( grid );
592
593
      Platform.runLater( () -> textArea.requestFocus() );
594
595
      dialog.setResultConverter( button -> {
596
        return (button == saveButton) ? textArea.getText() : "";
597
      } );
598
599
      final Optional<String> result = dialog.showAndWait();
600
601
      result.ifPresent( s -> {
602
        try {
603
          getSettings().saveRStartupScript( s );
604
        } catch( IOException ex ) {
605
          getNotifier().notify( ex );
606
        }
607
      } );
608
609
    } catch( final IOException ex ) {
610
      getNotifier().notify( ex );
611
    }
612
  }
613
614
  protected Scene getScene() {
615
    if( this.scene == null ) {
616
      this.scene = createScene();
617
    }
618
619
    return this.scene;
620
  }
621
622
  public Window getWindow() {
623
    return getScene().getWindow();
624
  }
625
626
  private MarkdownEditorPane getActiveEditor() {
627
    final EditorPane pane = getActiveFileEditor().getEditorPane();
628
629
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
630
  }
631
632
  private FileEditorTab getActiveFileEditor() {
633
    return getFileEditorPane().getActiveFileEditor();
634
  }
635
636
  //---- Member accessors ---------------------------------------------------
637
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
638
    this.processors = map;
639
  }
640
641
  private Map<FileEditorTab, Processor<String>> getProcessors() {
642
    if( this.processors == null ) {
643
      setProcessors( new HashMap<>() );
644
    }
645
646
    return this.processors;
647
  }
648
649
  private FileEditorTabPane getFileEditorPane() {
650
    if( this.fileEditorPane == null ) {
651
      this.fileEditorPane = createFileEditorPane();
652
    }
653
654
    return this.fileEditorPane;
655
  }
656
657
  private HTMLPreviewPane getPreviewPane() {
658
    if( this.previewPane == null ) {
659
      this.previewPane = createPreviewPane();
660
    }
661
662
    return this.previewPane;
663
  }
664
665
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
666
    this.definitionSource = definitionSource;
667
  }
668
669
  private DefinitionSource getDefinitionSource() {
670
    if( this.definitionSource == null ) {
671
      this.definitionSource = new EmptyDefinitionSource();
672
    }
673
674
    return this.definitionSource;
675
  }
676
677
  private DefinitionPane getDefinitionPane() {
678
    if( this.definitionPane == null ) {
679
      this.definitionPane = createDefinitionPane();
680
    }
681
682
    return this.definitionPane;
683
  }
684
685
  private Options getOptions() {
686
    return this.options;
687
  }
688
689
  private Snitch getSnitch() {
690
    return this.snitch;
691
  }
692
693
  private Notifier getNotifier() {
694
    return this.notifier;
695
  }
696
697
  public void setMenuBar( final MenuBar menuBar ) {
698
    this.menuBar = menuBar;
699
  }
700
701
  public MenuBar getMenuBar() {
702
    return this.menuBar;
703
  }
704
705
  private Text getLineNumberText() {
706
    if( this.lineNumberText == null ) {
707
      this.lineNumberText = createLineNumberText();
708
    }
709
710
    return this.lineNumberText;
711
  }
712
713
  private synchronized StatusBar getStatusBar() {
714
    if( this.statusBar == null ) {
715
      this.statusBar = createStatusBar();
716
    }
717
718
    return this.statusBar;
719
  }
720
721
  private TextField getFindTextField() {
722
    if( this.findTextField == null ) {
723
      this.findTextField = createFindTextField();
724
    }
725
726
    return this.findTextField;
727
  }
728
729
  //---- Member creators ----------------------------------------------------
730
  /**
731
   * Factory to create processors that are suited to different file types.
732
   *
733
   * @param tab The tab that is subjected to processing.
734
   *
735
   * @return A processor suited to the file type specified by the tab's path.
736
   */
737
  private Processor<String> createProcessor( final FileEditorTab tab ) {
738
    return createProcessorFactory().createProcessor( tab );
739
  }
740
741
  private ProcessorFactory createProcessorFactory() {
742
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
743
  }
744
745
  private DefinitionSource createDefinitionSource( final String path ) {
746
    final DefinitionSource ds
747
      = createDefinitionFactory().createDefinitionSource( path );
748
749
    if( ds instanceof FileDefinitionSource ) {
750
      try {
751
        getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
752
      } catch( final IOException ex ) {
753
        error( ex );
754
      }
755
    }
756
757
    return ds;
758
  }
759
760
  /**
761
   * Create an editor pane to hold file editor tabs.
762
   *
763
   * @return A new instance, never null.
764
   */
765
  private FileEditorTabPane createFileEditorPane() {
766
    return new FileEditorTabPane();
767
  }
768
769
  private HTMLPreviewPane createPreviewPane() {
770
    return new HTMLPreviewPane();
771
  }
772
773
  private DefinitionPane createDefinitionPane() {
774
    return new DefinitionPane( getTreeView() );
775
  }
776
777
  private DefinitionFactory createDefinitionFactory() {
778
    return new DefinitionFactory();
779
  }
780
781
  private StatusBar createStatusBar() {
782
    return new StatusBar();
783
  }
784
785
  private Scene createScene() {
786
    final SplitPane splitPane = new SplitPane(
787
      getDefinitionPane().getNode(),
788
      getFileEditorPane().getNode(),
789
      getPreviewPane().getNode() );
790
791
    splitPane.setDividerPositions(
792
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
793
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
794
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
795
796
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
797
    final BorderPane borderPane = new BorderPane();
798
    borderPane.setPrefSize( 1024, 800 );
799
    borderPane.setTop( createMenuBar() );
800
    borderPane.setBottom( getStatusBar() );
801
    borderPane.setCenter( splitPane );
802
803
    final VBox box = new VBox();
804
    box.setAlignment( Pos.BASELINE_CENTER );
805
    box.getChildren().add( getLineNumberText() );
806
    getStatusBar().getRightItems().add( box );
807
808
    return new Scene( borderPane );
809
  }
810
811
  private Text createLineNumberText() {
812
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
813
  }
814
815
  private Node createMenuBar() {
816
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
817
818
    // File actions
819
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
820
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
821
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
822
    final Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
823
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
824
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
825
    final Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
826
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
827
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
828
829
    // Edit actions
830
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
831
      e -> getActiveEditor().undo(),
832
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
833
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
834
      e -> getActiveEditor().redo(),
835
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
836
    final Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
837
      e -> find(),
838
      activeFileEditorIsNull );
839
    final Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
840
      e -> getActiveEditor().replace(),
841
      activeFileEditorIsNull );
842
    final Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
843
      e -> findNext(),
844
      activeFileEditorIsNull );
845
    final Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
846
      e -> getActiveEditor().findPrevious(),
847
      activeFileEditorIsNull );
848
849
    // Insert actions
850
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
851
      e -> getActiveEditor().surroundSelection( "**", "**" ),
852
      activeFileEditorIsNull );
853
    final Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
854
      e -> getActiveEditor().surroundSelection( "*", "*" ),
855
      activeFileEditorIsNull );
856
    final Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
857
      e -> getActiveEditor().surroundSelection( "^", "^" ),
858
      activeFileEditorIsNull );
859
    final Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
860
      e -> getActiveEditor().surroundSelection( "~", "~" ),
861
      activeFileEditorIsNull );
862
    final Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
863
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
864
      activeFileEditorIsNull );
865
    final Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
866
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
867
      activeFileEditorIsNull );
868
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
869
      e -> getActiveEditor().surroundSelection( "`", "`" ),
870
      activeFileEditorIsNull );
871
    final Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
872
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
873
      activeFileEditorIsNull );
874
875
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
876
      e -> getActiveEditor().insertLink(),
877
      activeFileEditorIsNull );
878
    final Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
879
      e -> getActiveEditor().insertImage(),
880
      activeFileEditorIsNull );
881
882
    final Action[] headers = new Action[ 6 ];
883
884
    // Insert header actions (H1 ... H6)
885
    for( int i = 1; i <= 6; i++ ) {
886
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
887
      final String markup = String.format( "%n%n%s ", hashes );
888
      final String text = get( "Main.menu.insert.header_" + i );
889
      final String accelerator = "Shortcut+" + i;
890
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
891
892
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
893
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
894
        activeFileEditorIsNull );
895
    }
896
897
    final Action insertUnorderedListAction = new Action(
898
      get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
899
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
900
      activeFileEditorIsNull );
901
    final Action insertOrderedListAction = new Action(
902
      get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
903
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
904
      activeFileEditorIsNull );
905
    final Action insertHorizontalRuleAction = new Action(
906
      get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
907
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
908
      activeFileEditorIsNull );
909
910
    // Tools actions
911
    final Action toolsScriptAction = new Action(
912
      get( "Main.menu.tools.script" ), null, null, e -> toolsScript() );
913
914
    // Help actions
915
    final Action helpAboutAction = new Action(
916
      get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
917
918
    //---- MenuBar ----
919
    final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
920
      fileNewAction,
921
      fileOpenAction,
922
      null,
923
      fileCloseAction,
924
      fileCloseAllAction,
925
      null,
926
      fileSaveAction,
927
      fileSaveAllAction,
928
      null,
929
      fileExitAction );
930
931
    final Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
932
      editUndoAction,
933
      editRedoAction,
934
      editFindAction,
935
      editReplaceAction,
936
      editFindNextAction,
937
      editFindPreviousAction );
938
939
    final Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
940
      insertBoldAction,
941
      insertItalicAction,
942
      insertSuperscriptAction,
943
      insertSubscriptAction,
944
      insertStrikethroughAction,
945
      insertBlockquoteAction,
946
      insertCodeAction,
947
      insertFencedCodeBlockAction,
948
      null,
949
      insertLinkAction,
950
      insertImageAction,
951
      null,
952
      headers[ 0 ],
953
      headers[ 1 ],
954
      headers[ 2 ],
955
      headers[ 3 ],
956
      headers[ 4 ],
957
      headers[ 5 ],
958
      null,
959
      insertUnorderedListAction,
960
      insertOrderedListAction,
961
      insertHorizontalRuleAction );
962
963
    final Menu toolsMenu = ActionUtils.createMenu( get( "Main.menu.tools" ),
964
      toolsScriptAction );
965
966
    final Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
967
      helpAboutAction );
968
969
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, toolsMenu, helpMenu );
970
971
    //---- ToolBar ----
972
    ToolBar toolBar = ActionUtils.createToolBar(
973
      fileNewAction,
974
      fileOpenAction,
975
      fileSaveAction,
976
      null,
977
      editUndoAction,
978
      editRedoAction,
979
      null,
980
      insertBoldAction,
981
      insertItalicAction,
982
      insertSuperscriptAction,
983
      insertSubscriptAction,
984
      insertBlockquoteAction,
985
      insertCodeAction,
986
      insertFencedCodeBlockAction,
987
      null,
988
      insertLinkAction,
989
      insertImageAction,
990
      null,
991
      headers[ 0 ],
992
      null,
993
      insertUnorderedListAction,
994
      insertOrderedListAction );
995
996
    return new VBox( menuBar, toolBar );
997
  }
998
999
  /**
1000
   * Creates a boolean property that is bound to another boolean value of the
1001
   * active editor.
1002
   */
1003
  private BooleanProperty createActiveBooleanProperty(
1004
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
1005
1006
    final BooleanProperty b = new SimpleBooleanProperty();
1007
    final FileEditorTab tab = getActiveFileEditor();
1008
1009
    if( tab != null ) {
1010
      b.bind( func.apply( tab ) );
1011
    }
1012
1013
    getFileEditorPane().activeFileEditorProperty().addListener(
1014
      (observable, oldFileEditor, newFileEditor) -> {
1015
        b.unbind();
1016
1017
        if( newFileEditor != null ) {
1018
          b.bind( func.apply( newFileEditor ) );
1019
        }
1020
        else {
1021
          b.set( false );
1022
        }
1023
      }
1024
    );
1025
1026
    return b;
1027
  }
1028
1029
  private void initLayout() {
1030
    final Scene appScene = getScene();
1031
1032
    appScene.getStylesheets().add( STYLESHEET_SCENE );
1033
//    appScene.getStylesheets().add( STYLESHEET_XML );
1034
1035
    appScene.windowProperty().addListener(
1036
      (observable, oldWindow, newWindow) -> {
1037
        newWindow.setOnCloseRequest( e -> {
1038
          if( !getFileEditorPane().closeAllEditors() ) {
1039
            e.consume();
1040
          }
1041
        } );
1042
1043
        // Workaround JavaFX bug: deselect menubar if window loses focus.
1044
        newWindow.focusedProperty().addListener(
1045
          (obs, oldFocused, newFocused) -> {
1046
            if( !newFocused ) {
1047
              // Send an ESC key event to the menubar
1048
              this.menuBar.fireEvent(
1049
                new KeyEvent(
1050
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
1051
                  false, false, false, false ) );
1052
            }
1053
          }
1054
        );
1055
      }
1056
    );
1057
  }
10581058
}
10591059
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
3434
import static com.scrivenvar.decorators.RVariableDecorator.SUFFIX;
3535
import static com.scrivenvar.processors.text.TextReplacementFactory.replace;
36
import com.scrivenvar.service.Settings;
3637
import com.scrivenvar.service.events.Notifier;
38
import java.io.IOException;
3739
import static java.lang.Math.min;
3840
import java.nio.file.Path;
...
4850
 */
4951
public final class InlineRProcessor extends DefaultVariableProcessor {
50
51
  private ScriptEngine engine;
5252
5353
  private final Notifier notifier = Services.load( Notifier.class );
54
  private final Settings settings = Services.load( Settings.class );
55
56
  private ScriptEngine engine;
5457
5558
  /**
...
7477
   */
7578
  private void init( final Path workingDirectory ) {
76
    // In Windows, path characters must be changed from escape chars.
7779
    try {
78
      eval( replace( ""
79
        + "assign( 'anchor', as.Date( '$date.anchor$', format='%Y-%m-%d' ), envir = .GlobalEnv );"
80
        + "setwd( '" + workingDirectory.toString().replace( '\\', '/' ) + "' );"
81
        + "source( '../bin/pluralize.R' );"
82
        + "source( '../bin/common.R' )", getDefinitions() ) );
83
    } catch( final Exception e ) {
80
      final String dir = workingDirectory.toString().replace( '\\', '/' );
81
      final Map<String, String> definitions = getDefinitions();
82
      definitions.put( "$application.r.working.directory$", dir );
83
84
      final String initScript = getInitScript();
85
      final String rScript = replace( initScript, getDefinitions() );
86
87
      eval( rScript );
88
    } catch( final IOException | ScriptException e ) {
8489
      throw new RuntimeException( e );
8590
    }
91
  }
92
93
  private String getInitScript() throws IOException {
94
    return getSettings().loadRStartupScript();
8695
  }
8796
...
169178
  private Notifier getNotifier() {
170179
    return this.notifier;
180
  }
181
182
  private Settings getSettings() {
183
    return this.settings;
171184
  }
172185
}
M src/main/java/com/scrivenvar/service/Settings.java
2828
package com.scrivenvar.service;
2929
30
import java.io.IOException;
3031
import java.util.Iterator;
3132
import java.util.List;
...
9293
   */
9394
  public List<String> getStringSettingList( String property );
95
96
  /**
97
   * Reads the R startup script into a string, or the empty string if the file
98
   * could not be read (or found). The R startup file must be UTF-8.
99
   *
100
   * @return The string content for the R startup script, or empty if not found.
101
   *
102
   * @throws IOException Could not read the R startup script.
103
   */
104
  public String loadRStartupScript() throws IOException;
105
106
  /**
107
   * Writes the R startup script into its predefined location.
108
   *
109
   * @param script The string content for the R startup script.
110
   * @throws IOException Could not read the R startup script.
111
   */
112
  public void saveRStartupScript( final String script ) throws IOException;
94113
}
95114
M src/main/java/com/scrivenvar/service/impl/DefaultSettings.java
2828
package com.scrivenvar.service.impl;
2929
30
import static com.scrivenvar.Constants.FILE_R_STARTUP;
3031
import static com.scrivenvar.Constants.SETTINGS_NAME;
32
import com.scrivenvar.processors.InlineRProcessor;
3133
import com.scrivenvar.service.Settings;
34
import java.io.ByteArrayOutputStream;
3235
import java.io.IOException;
36
import java.io.InputStream;
3337
import java.io.InputStreamReader;
3438
import java.io.Reader;
3539
import java.net.URISyntaxException;
3640
import java.net.URL;
3741
import java.nio.charset.Charset;
42
import static java.nio.charset.StandardCharsets.UTF_8;
3843
import java.util.Iterator;
3944
import java.util.List;
...
163168
  private PropertiesConfiguration getSettings() {
164169
    return this.properties;
170
  }
171
  
172
  /**
173
   * 
174
   * @param script Script to write file back to the settings.
175
   * @throws IOException Couldn't write the string back to the file.
176
   */
177
  @Override
178
  public void saveRStartupScript( final String script ) throws IOException {
179
    assert script != null;
180
    
181
    System.out.println( "Save resource: " + script );
182
  }
183
184
  /**
185
   * Reads the R startup script into a string, or the empty string if the file
186
   * could not be read (or found). The R startup file must be UTF-8.
187
   *
188
   * @return The string content for the R startup script, or empty if not found.
189
   * @throws IOException Could not read the R startup script.
190
   */
191
  @Override
192
  public String loadRStartupScript() throws IOException {
193
    try( final InputStream in = openResource( FILE_R_STARTUP ) ) {
194
      return readFully( in );
195
    }
196
  }
197
198
  /**
199
   * Opens a resource such that it can be closed using a try/finally block.
200
   *
201
   * @param path Path to the resource to open.
202
   *
203
   * @return An open input stream ready to be read.
204
   */
205
  private InputStream openResource( final String path ) {
206
    return InlineRProcessor.class.getResourceAsStream( path );
207
  }
208
209
  private String readFully( final InputStream inputStream ) throws IOException {
210
    final byte[] buffer = new byte[ 8192 ];
211
    final ByteArrayOutputStream result = new ByteArrayOutputStream();
212
213
    int length;
214
215
    while( (length = inputStream.read( buffer )) != -1 ) {
216
      result.write( buffer, 0, length );
217
    }
218
219
    return result.toString( UTF_8.name() );
165220
  }
221
166222
}
167223
M src/main/resources/com/scrivenvar/messages.properties
8484
8585
Main.menu.tools=_Tools
86
Main.menu.tools.options=Options
86
Main.menu.tools.script=_R Script
8787
8888
Main.menu.help=_Help
M src/main/resources/com/scrivenvar/settings.properties
4141
file.logo.512=${application.package}/logo512.png
4242
43
# Startup script for R
44
file.r.startup=/${application.package}/startup.R
45
4346
# ########################################################################
4447
#
A src/main/resources/com/scrivenvar/startup.R
11