Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
2323
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
2424
  implementation 'com.miglayout:miglayout-javafx:5.2'
25
  implementation 'com.vladsch.flexmark:flexmark:0.62.0'
26
  implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.0'
27
  implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.0'
28
  implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.0'
25
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.6.0'
26
  implementation 'com.vladsch.flexmark:flexmark:0.62.2'
27
  implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.2'
28
  implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.2'
29
  implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.2'
2930
  implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0'
3031
  implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.0'
...
3940
  implementation 'de.jensd:fontawesomefx-commons:11.0'
4041
  implementation 'de.jensd:fontawesomefx-fontawesome:4.7.0-11'
41
  implementation "org.renjin:renjin-script-engine:3.5-beta76"
42
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
43
  implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.20'
44
  implementation 'org.jsoup:jsoup:1.13.1'
45
  implementation 'org.apache.xmlgraphics:batik-all:1.13'
4246
4347
  def os = ['win', 'linux', 'mac']
44
  def fx = ['controls', 'graphics', 'web', 'fxml']
48
  def fx = ['controls', 'graphics', 'fxml', 'swing']
4549
4650
  fx.each { fxitem ->
...
5660
javafx {
5761
  version = "14"
58
  modules = ['javafx.controls', 'javafx.graphics', 'javafx.web']
62
  modules = ['javafx.controls', 'javafx.graphics', 'javafx.swing']
5963
}
6064
6165
compileJava {
6266
  options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
6367
}
6468
6569
sourceCompatibility = JavaVersion.VERSION_11
6670
applicationName = 'scrivenvar'
67
version gitVersion()
71
version = gitVersion()
6872
mainClassName = "com.${applicationName}.Main"
6973
def launcherClassName = "com.${applicationName}.Launcher"
M src/main/java/com/scrivenvar/Constants.java
6363
  // Prevent double events when updating files on Linux (save and timestamp).
6464
  public static final int APP_WATCHDOG_TIMEOUT = get(
65
      "application.watchdog.timeout", 100 );
65
      "application.watchdog.timeout", 200 );
6666
6767
  public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" );
...
7676
  public static final String FILE_LOGO_256 = get( "file.logo.256" );
7777
  public static final String FILE_LOGO_512 = get( "file.logo.512" );
78
79
  public static final String CARET_POSITION_BASE = get( "caret.token.base" );
80
  public static final String CARET_POSITION_MD = get( "caret.token.markdown" );
81
  public static final String CARET_POSITION_HTML = get( "caret.token.html" );
8278
8379
  public static final String PREFS_ROOT = get( "preferences.root" );
8480
  public static final String PREFS_STATE = get( "preferences.root.state" );
85
  public static final String PREFS_OPTIONS = get( "preferences.root.options" );
8681
8782
  // Refer to filename extension settings in the configuration file. Do not
...
106101
   */
107102
  public static final int DEFAULT_MAP_SIZE = 64;
108
109
  /**
110
   * Location of the definition source file.
111
   */
112
  public static final String PERSIST_DEFINITION_SOURCE = "definitionSource";
113
114
  /**
115
   * Content of the R startup script.
116
   */
117
  public static final String PERSIST_R_STARTUP = "rStartup";
118103
119
  /**
120
   * Bootstrap directory for R startup script.
121
   */
122
  public static final String PERSIST_R_DIRECTORY = "rDirectory";
104
  public static final String PERSIST_IMAGES_DEFAULT =
105
      get( "file.stylesheet.scene" );
123106
124107
  /**
M src/main/java/com/scrivenvar/FileEditorTab.java
352352
   */
353353
  private Charset detectEncoding( final byte[] bytes ) {
354
    final UniversalDetector detector = new UniversalDetector( null );
354
    final var detector = new UniversalDetector( null );
355355
    detector.handleData( bytes, 0, bytes.length );
356356
    detector.dataEnd();
357357
358358
    final String charset = detector.getDetectedCharset();
359
    final Charset charEncoding = charset == null
359
360
    return charset == null
360361
        ? Charset.defaultCharset()
361362
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
362
363
    detector.reset();
364
365
    return charEncoding;
366363
  }
367364
M src/main/java/com/scrivenvar/FileEditorTabPane.java
5858
import java.util.Optional;
5959
import java.util.concurrent.atomic.AtomicReference;
60
import java.util.prefs.Preferences;
61
import java.util.stream.Collectors;
62
63
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
64
import static com.scrivenvar.FileType.*;
65
import static com.scrivenvar.Messages.get;
66
import static com.scrivenvar.service.events.Notifier.YES;
67
68
/**
69
 * Tab pane for file editors.
70
 *
71
 * @author Karl Tauber and White Magic Software, Ltd.
72
 */
73
public final class FileEditorTabPane extends TabPane {
74
75
  private final static String FILTER_EXTENSION_TITLES =
76
      "Dialog.file.choose.filter";
77
78
  private final Options mOptions = Services.load( Options.class );
79
  private final Settings mSettings = Services.load( Settings.class );
80
  private final Notifier mNotifyService = Services.load( Notifier.class );
81
82
  private final ReadOnlyObjectWrapper<Path> openDefinition =
83
      new ReadOnlyObjectWrapper<>();
84
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
85
      new ReadOnlyObjectWrapper<>();
86
  private final ReadOnlyBooleanWrapper anyFileEditorModified =
87
      new ReadOnlyBooleanWrapper();
88
89
  /**
90
   * Constructs a new file editor tab pane.
91
   */
92
  public FileEditorTabPane() {
93
    final ObservableList<Tab> tabs = getTabs();
94
95
    setFocusTraversable( false );
96
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
97
98
    addTabSelectionListener(
99
        ( ObservableValue<? extends Tab> tabPane,
100
          final Tab oldTab, final Tab newTab ) -> {
101
102
          if( newTab != null ) {
103
            mActiveFileEditor.set( (FileEditorTab) newTab );
104
          }
105
        }
106
    );
107
108
    final ChangeListener<Boolean> modifiedListener = ( observable, oldValue,
109
                                                       newValue ) -> {
110
      for( final Tab tab : tabs ) {
111
        if( ((FileEditorTab) tab).isModified() ) {
112
          this.anyFileEditorModified.set( true );
113
          break;
114
        }
115
      }
116
    };
117
118
    tabs.addListener(
119
        (ListChangeListener<Tab>) change -> {
120
          while( change.next() ) {
121
            if( change.wasAdded() ) {
122
              change.getAddedSubList().forEach(
123
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
124
                                                  .addListener( modifiedListener ) );
125
            }
126
            else if( change.wasRemoved() ) {
127
              change.getRemoved().forEach(
128
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
129
                                                  .removeListener(
130
                                                      modifiedListener ) );
131
            }
132
          }
133
134
          // Changes in the tabs may also change anyFileEditorModified property
135
          // (e.g. closed modified file)
136
          modifiedListener.changed( null, null, null );
137
        }
138
    );
139
  }
140
141
  /**
142
   * Allows observers to be notified when the current file editor tab changes.
143
   *
144
   * @param listener The listener to notify of tab change events.
145
   */
146
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
147
    // Observe the tab so that when a new tab is opened or selected,
148
    // a notification is kicked off.
149
    getSelectionModel().selectedItemProperty().addListener( listener );
150
  }
151
152
  /**
153
   * Returns the tab that has keyboard focus.
154
   *
155
   * @return A non-null instance.
156
   */
157
  public FileEditorTab getActiveFileEditor() {
158
    return mActiveFileEditor.get();
159
  }
160
161
  /**
162
   * Returns the property corresponding to the tab that has focus.
163
   *
164
   * @return A non-null instance.
165
   */
166
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
167
    return mActiveFileEditor.getReadOnlyProperty();
168
  }
169
170
  /**
171
   * Property that can answer whether the text has been modified.
172
   *
173
   * @return A non-null instance, true meaning the content has not been saved.
174
   */
175
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
176
    return this.anyFileEditorModified.getReadOnlyProperty();
177
  }
178
179
  /**
180
   * Creates a new editor instance from the given path.
181
   *
182
   * @param path The file to open.
183
   * @return A non-null instance.
184
   */
185
  private FileEditorTab createFileEditor( final Path path ) {
186
    assert path != null;
187
188
    final FileEditorTab tab = new FileEditorTab( path );
189
190
    tab.setOnCloseRequest( e -> {
191
      if( !canCloseEditor( tab ) ) {
192
        e.consume();
193
      }
194
      else if( isActiveFileEditor( tab ) ) {
195
        // Prevent prompting the user to save when there are no file editor
196
        // tabs open.
197
        mActiveFileEditor.set( null );
198
      }
199
    } );
200
201
    return tab;
202
  }
203
204
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
205
    return getActiveFileEditor() == tab;
206
  }
207
208
  private Path getDefaultPath() {
209
    final String filename = getDefaultFilename();
210
    return (new File( filename )).toPath();
211
  }
212
213
  private String getDefaultFilename() {
214
    return getSettings().getSetting( "file.default", "untitled.md" );
215
  }
216
217
  /**
218
   * Called when the user selects New from the File menu.
219
   */
220
  void newEditor() {
221
    final Path defaultPath = getDefaultPath();
222
    final FileEditorTab tab = createFileEditor( defaultPath );
223
224
    getTabs().add( tab );
225
    getSelectionModel().select( tab );
226
  }
227
228
  void openFileDialog() {
229
    final String title = get( "Dialog.file.choose.open.title" );
230
    final FileChooser dialog = createFileChooser( title );
231
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
232
233
    if( files != null ) {
234
      openFiles( files );
235
    }
236
  }
237
238
  /**
239
   * Opens the files into new editors, unless one of those files was a
240
   * definition file. The definition file is loaded into the definition pane,
241
   * but only the first one selected (multiple definition files will result in a
242
   * warning).
243
   *
244
   * @param files The list of non-definition files that the were requested to
245
   *              open.
246
   */
247
  private void openFiles( final List<File> files ) {
248
    final List<String> extensions =
249
        createExtensionFilter( DEFINITION ).getExtensions();
250
    final FileTypePredicate predicate =
251
        new FileTypePredicate( extensions );
252
253
    // The user might have opened multiple definitions files. These will
254
    // be discarded from the text editable files.
255
    final List<File> definitions
256
        = files.stream().filter( predicate ).collect( Collectors.toList() );
257
258
    // Create a modifiable list to remove any definition files that were
259
    // opened.
260
    final List<File> editors = new ArrayList<>( files );
261
262
    if( !editors.isEmpty() ) {
263
      saveLastDirectory( editors.get( 0 ) );
264
    }
265
266
    editors.removeAll( definitions );
267
268
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
269
    if( !editors.isEmpty() ) {
270
      openEditors( editors, 0 );
271
    }
272
273
    if( !definitions.isEmpty() ) {
274
      openDefinition( definitions.get( 0 ) );
275
    }
276
  }
277
278
  private void openEditors( final List<File> files, final int activeIndex ) {
279
    final int fileTally = files.size();
280
    final List<Tab> tabs = getTabs();
281
282
    // Close single unmodified "Untitled" tab.
283
    if( tabs.size() == 1 ) {
284
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
285
286
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
287
        closeEditor( fileEditor, false );
288
      }
289
    }
290
291
    for( int i = 0; i < fileTally; i++ ) {
292
      final Path path = files.get( i ).toPath();
293
294
      FileEditorTab fileEditorTab = findEditor( path );
295
296
      // Only open new files.
297
      if( fileEditorTab == null ) {
298
        fileEditorTab = createFileEditor( path );
299
        getTabs().add( fileEditorTab );
300
      }
301
302
      // Select the first file in the list.
303
      if( i == activeIndex ) {
304
        getSelectionModel().select( fileEditorTab );
305
      }
306
    }
307
  }
308
309
  /**
310
   * Returns a property that changes when a new definition file is opened.
311
   *
312
   * @return The path to a definition file that was opened.
313
   */
314
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
315
    return getOnOpenDefinitionFile().getReadOnlyProperty();
316
  }
317
318
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
319
    return this.openDefinition;
320
  }
321
322
  /**
323
   * Called when the user has opened a definition file (using the file open
324
   * dialog box). This will replace the current set of definitions for the
325
   * active tab.
326
   *
327
   * @param definition The file to open.
328
   */
329
  private void openDefinition( final File definition ) {
330
    // TODO: Prevent reading this file twice when a new text document is opened.
331
    // (might be a matter of checking the value first).
332
    getOnOpenDefinitionFile().set( definition.toPath() );
333
  }
334
335
  /**
336
   * Called when the contents of the editor are to be saved.
337
   *
338
   * @param tab The tab containing content to save.
339
   * @return true The contents were saved (or needn't be saved).
340
   */
341
  public boolean saveEditor( final FileEditorTab tab ) {
342
    if( tab == null || !tab.isModified() ) {
343
      return true;
344
    }
345
346
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
347
  }
348
349
  /**
350
   * Opens the Save As dialog for the user to save the content under a new
351
   * path.
352
   *
353
   * @param tab The tab with contents to save.
354
   * @return true The contents were saved, or the tab was null.
355
   */
356
  public boolean saveEditorAs( final FileEditorTab tab ) {
357
    if( tab == null ) {
358
      return true;
359
    }
360
361
    getSelectionModel().select( tab );
362
363
    final FileChooser fileChooser = createFileChooser( get(
364
        "Dialog.file.choose.save.title" ) );
365
    final File file = fileChooser.showSaveDialog( getWindow() );
366
    if( file == null ) {
367
      return false;
368
    }
369
370
    saveLastDirectory( file );
371
    tab.setPath( file.toPath() );
372
373
    return tab.save();
374
  }
375
376
  void saveAllEditors() {
377
    for( final FileEditorTab fileEditor : getAllEditors() ) {
378
      saveEditor( fileEditor );
379
    }
380
  }
381
382
  /**
383
   * Answers whether the file has had modifications. '
384
   *
385
   * @param tab THe tab to check for modifications.
386
   * @return false The file is unmodified.
387
   */
388
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
389
  boolean canCloseEditor( final FileEditorTab tab ) {
390
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
391
    canClose.set( true );
392
393
    if( tab.isModified() ) {
394
      final Notification message = getNotifyService().createNotification(
395
          Messages.get( "Alert.file.close.title" ),
396
          Messages.get( "Alert.file.close.text" ),
397
          tab.getText()
398
      );
399
400
      final Alert confirmSave = getNotifyService().createConfirmation(
401
          getWindow(), message );
402
403
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
404
405
      buttonType.ifPresent(
406
          save -> canClose.set(
407
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
408
          )
409
      );
410
    }
411
412
    return canClose.get();
413
  }
414
415
  private Notifier getNotifyService() {
416
    return this.mNotifyService;
417
  }
418
419
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
420
    if( tab == null ) {
421
      return true;
422
    }
423
424
    if( save ) {
425
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
426
      Event.fireEvent( tab, event );
427
428
      if( event.isConsumed() ) {
429
        return false;
430
      }
431
    }
432
433
    getTabs().remove( tab );
434
435
    if( tab.getOnClosed() != null ) {
436
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
437
    }
438
439
    return true;
440
  }
441
442
  boolean closeAllEditors() {
443
    final FileEditorTab[] allEditors = getAllEditors();
444
    final FileEditorTab activeEditor = getActiveFileEditor();
445
446
    // try to save active tab first because in case the user decides to cancel,
447
    // then it stays active
448
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
449
      return false;
450
    }
451
452
    // This should be called any time a tab changes.
453
    persistPreferences();
454
455
    // save modified tabs
456
    for( int i = 0; i < allEditors.length; i++ ) {
457
      final FileEditorTab fileEditor = allEditors[ i ];
458
459
      if( fileEditor == activeEditor ) {
460
        continue;
461
      }
462
463
      if( fileEditor.isModified() ) {
464
        // activate the modified tab to make its modified content visible to
465
        // the user
466
        getSelectionModel().select( i );
467
468
        if( !canCloseEditor( fileEditor ) ) {
469
          return false;
470
        }
471
      }
472
    }
473
474
    // Close all tabs.
475
    for( final FileEditorTab fileEditor : allEditors ) {
476
      if( !closeEditor( fileEditor, false ) ) {
477
        return false;
478
      }
479
    }
480
481
    return getTabs().isEmpty();
482
  }
483
484
  private FileEditorTab[] getAllEditors() {
485
    final ObservableList<Tab> tabs = getTabs();
486
    final int length = tabs.size();
487
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
488
489
    for( int i = 0; i < length; i++ ) {
490
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
491
    }
492
493
    return allEditors;
494
  }
495
496
  /**
497
   * Returns the file editor tab that has the given path.
498
   *
499
   * @return null No file editor tab for the given path was found.
500
   */
501
  private FileEditorTab findEditor( final Path path ) {
502
    for( final Tab tab : getTabs() ) {
503
      final FileEditorTab fileEditor = (FileEditorTab) tab;
504
505
      if( fileEditor.isPath( path ) ) {
506
        return fileEditor;
507
      }
508
    }
509
510
    return null;
511
  }
512
513
  private FileChooser createFileChooser( String title ) {
514
    final FileChooser fileChooser = new FileChooser();
515
516
    fileChooser.setTitle( title );
517
    fileChooser.getExtensionFilters().addAll(
518
        createExtensionFilters() );
519
520
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
521
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
522
523
    if( !file.isDirectory() ) {
524
      file = new File( "." );
525
    }
526
527
    fileChooser.setInitialDirectory( file );
528
    return fileChooser;
529
  }
530
531
  private List<ExtensionFilter> createExtensionFilters() {
532
    final List<ExtensionFilter> list = new ArrayList<>();
533
534
    // TODO: Return a list of all properties that match the filter prefix.
535
    // This will allow dynamic filters to be added and removed just by
536
    // updating the properties file.
537
    list.add( createExtensionFilter( ALL ) );
538
    list.add( createExtensionFilter( SOURCE ) );
539
    list.add( createExtensionFilter( DEFINITION ) );
540
    list.add( createExtensionFilter( XML ) );
541
    return list;
542
  }
543
544
  /**
545
   * Returns a filter for file name extensions recognized by the application
546
   * that can be opened by the user.
547
   *
548
   * @param filetype Used to find the globbing pattern for extensions.
549
   * @return A filename filter suitable for use by a FileDialog instance.
550
   */
551
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
552
    final String tKey = String.format( "%s.title.%s",
553
                                       FILTER_EXTENSION_TITLES,
554
                                       filetype );
555
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
556
557
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
558
  }
559
560
  private List<String> getExtensions( final String key ) {
561
    return getSettings().getStringSettingList( key );
562
  }
563
564
  private void saveLastDirectory( final File file ) {
565
    getPreferences().put( "lastDirectory", file.getParent() );
566
  }
567
568
  public void restorePreferences() {
569
    int activeIndex = 0;
570
571
    final Preferences preferences = getPreferences();
572
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
573
    final String activeFileName = preferences.get( "activeFile", null );
574
575
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
576
577
    for( final String fileName : fileNames ) {
578
      final File file = new File( fileName );
579
580
      if( file.exists() ) {
581
        files.add( file );
582
583
        if( fileName.equals( activeFileName ) ) {
584
          activeIndex = files.size() - 1;
585
        }
586
      }
587
    }
588
589
    if( files.isEmpty() ) {
590
      newEditor();
591
    }
592
    else {
593
      openEditors( files, activeIndex );
594
    }
595
  }
596
597
  public void persistPreferences() {
598
    final ObservableList<Tab> allEditors = getTabs();
599
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
600
601
    for( final Tab tab : allEditors ) {
602
      final FileEditorTab fileEditor = (FileEditorTab) tab;
603
      final Path filePath = fileEditor.getPath();
604
605
      if( filePath != null ) {
606
        fileNames.add( filePath.toString() );
607
      }
608
    }
609
610
    final Preferences preferences = getPreferences();
611
    Utils.putPrefsStrings( preferences,
612
                           "file",
613
                           fileNames.toArray( new String[ 0 ] ) );
614
615
    final FileEditorTab activeEditor = getActiveFileEditor();
616
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
617
618
    if( filePath == null ) {
619
      preferences.remove( "activeFile" );
620
    }
621
    else {
622
      preferences.put( "activeFile", filePath.toString() );
623
    }
624
  }
625
626
  private Settings getSettings() {
627
    return mSettings;
628
  }
629
630
  protected Options getOptions() {
631
    return mOptions;
60
import java.util.function.Consumer;
61
import java.util.prefs.Preferences;
62
import java.util.stream.Collectors;
63
64
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
65
import static com.scrivenvar.FileType.*;
66
import static com.scrivenvar.Messages.get;
67
import static com.scrivenvar.service.events.Notifier.YES;
68
69
/**
70
 * Tab pane for file editors.
71
 *
72
 * @author Karl Tauber and White Magic Software, Ltd.
73
 */
74
public final class FileEditorTabPane extends TabPane {
75
76
  private final static String FILTER_EXTENSION_TITLES =
77
      "Dialog.file.choose.filter";
78
79
  private final static Options sOptions = Services.load( Options.class );
80
  private final static Settings sSettings = Services.load( Settings.class );
81
  private final static Notifier sNotifier = Services.load( Notifier.class );
82
83
  private final ReadOnlyObjectWrapper<Path> openDefinition =
84
      new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
86
      new ReadOnlyObjectWrapper<>();
87
  private final ReadOnlyBooleanWrapper anyFileEditorModified =
88
      new ReadOnlyBooleanWrapper();
89
  private final Consumer<Double> mScrollEventObserver;
90
91
  /**
92
   * Constructs a new file editor tab pane.
93
   */
94
  public FileEditorTabPane( final Consumer<Double> scrollEventObserver ) {
95
    final ObservableList<Tab> tabs = getTabs();
96
97
    setFocusTraversable( false );
98
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
99
100
    addTabSelectionListener(
101
        ( ObservableValue<? extends Tab> tabPane,
102
          final Tab oldTab, final Tab newTab ) -> {
103
104
          if( newTab != null ) {
105
            mActiveFileEditor.set( (FileEditorTab) newTab );
106
          }
107
        }
108
    );
109
110
    final ChangeListener<Boolean> modifiedListener = ( observable, oldValue,
111
                                                       newValue ) -> {
112
      for( final Tab tab : tabs ) {
113
        if( ((FileEditorTab) tab).isModified() ) {
114
          this.anyFileEditorModified.set( true );
115
          break;
116
        }
117
      }
118
    };
119
120
    tabs.addListener(
121
        (ListChangeListener<Tab>) change -> {
122
          while( change.next() ) {
123
            if( change.wasAdded() ) {
124
              change.getAddedSubList().forEach(
125
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
126
                                                  .addListener( modifiedListener ) );
127
            }
128
            else if( change.wasRemoved() ) {
129
              change.getRemoved().forEach(
130
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
131
                                                  .removeListener(
132
                                                      modifiedListener ) );
133
            }
134
          }
135
136
          // Changes in the tabs may also change anyFileEditorModified property
137
          // (e.g. closed modified file)
138
          modifiedListener.changed( null, null, null );
139
        }
140
    );
141
142
    mScrollEventObserver = scrollEventObserver;
143
  }
144
145
  /**
146
   * Allows observers to be notified when the current file editor tab changes.
147
   *
148
   * @param listener The listener to notify of tab change events.
149
   */
150
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
151
    // Observe the tab so that when a new tab is opened or selected,
152
    // a notification is kicked off.
153
    getSelectionModel().selectedItemProperty().addListener( listener );
154
  }
155
156
  /**
157
   * Returns the tab that has keyboard focus.
158
   *
159
   * @return A non-null instance.
160
   */
161
  public FileEditorTab getActiveFileEditor() {
162
    return mActiveFileEditor.get();
163
  }
164
165
  /**
166
   * Returns the property corresponding to the tab that has focus.
167
   *
168
   * @return A non-null instance.
169
   */
170
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
171
    return mActiveFileEditor.getReadOnlyProperty();
172
  }
173
174
  /**
175
   * Property that can answer whether the text has been modified.
176
   *
177
   * @return A non-null instance, true meaning the content has not been saved.
178
   */
179
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
180
    return this.anyFileEditorModified.getReadOnlyProperty();
181
  }
182
183
  /**
184
   * Creates a new editor instance from the given path.
185
   *
186
   * @param path The file to open.
187
   * @return A non-null instance.
188
   */
189
  private FileEditorTab createFileEditor( final Path path ) {
190
    assert path != null;
191
192
    final FileEditorTab tab = new FileEditorTab( path );
193
194
    tab.getEditorPane().getScrollPane().estimatedScrollYProperty().addObserver(
195
        mScrollEventObserver
196
    );
197
198
    tab.setOnCloseRequest( e -> {
199
      if( !canCloseEditor( tab ) ) {
200
        e.consume();
201
      }
202
      else if( isActiveFileEditor( tab ) ) {
203
        // Prevent prompting the user to save when there are no file editor
204
        // tabs open.
205
        mActiveFileEditor.set( null );
206
      }
207
    } );
208
209
    return tab;
210
  }
211
212
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
213
    return getActiveFileEditor() == tab;
214
  }
215
216
  private Path getDefaultPath() {
217
    final String filename = getDefaultFilename();
218
    return (new File( filename )).toPath();
219
  }
220
221
  private String getDefaultFilename() {
222
    return getSettings().getSetting( "file.default", "untitled.md" );
223
  }
224
225
  /**
226
   * Called when the user selects New from the File menu.
227
   */
228
  void newEditor() {
229
    final Path defaultPath = getDefaultPath();
230
    final FileEditorTab tab = createFileEditor( defaultPath );
231
232
    getTabs().add( tab );
233
    getSelectionModel().select( tab );
234
  }
235
236
  void openFileDialog() {
237
    final String title = get( "Dialog.file.choose.open.title" );
238
    final FileChooser dialog = createFileChooser( title );
239
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
240
241
    if( files != null ) {
242
      openFiles( files );
243
    }
244
  }
245
246
  /**
247
   * Opens the files into new editors, unless one of those files was a
248
   * definition file. The definition file is loaded into the definition pane,
249
   * but only the first one selected (multiple definition files will result in a
250
   * warning).
251
   *
252
   * @param files The list of non-definition files that the were requested to
253
   *              open.
254
   */
255
  private void openFiles( final List<File> files ) {
256
    final List<String> extensions =
257
        createExtensionFilter( DEFINITION ).getExtensions();
258
    final FileTypePredicate predicate =
259
        new FileTypePredicate( extensions );
260
261
    // The user might have opened multiple definitions files. These will
262
    // be discarded from the text editable files.
263
    final List<File> definitions
264
        = files.stream().filter( predicate ).collect( Collectors.toList() );
265
266
    // Create a modifiable list to remove any definition files that were
267
    // opened.
268
    final List<File> editors = new ArrayList<>( files );
269
270
    if( !editors.isEmpty() ) {
271
      saveLastDirectory( editors.get( 0 ) );
272
    }
273
274
    editors.removeAll( definitions );
275
276
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
277
    if( !editors.isEmpty() ) {
278
      openEditors( editors, 0 );
279
    }
280
281
    if( !definitions.isEmpty() ) {
282
      openDefinition( definitions.get( 0 ) );
283
    }
284
  }
285
286
  private void openEditors( final List<File> files, final int activeIndex ) {
287
    final int fileTally = files.size();
288
    final List<Tab> tabs = getTabs();
289
290
    // Close single unmodified "Untitled" tab.
291
    if( tabs.size() == 1 ) {
292
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
293
294
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
295
        closeEditor( fileEditor, false );
296
      }
297
    }
298
299
    for( int i = 0; i < fileTally; i++ ) {
300
      final Path path = files.get( i ).toPath();
301
302
      FileEditorTab fileEditorTab = findEditor( path );
303
304
      // Only open new files.
305
      if( fileEditorTab == null ) {
306
        fileEditorTab = createFileEditor( path );
307
        getTabs().add( fileEditorTab );
308
      }
309
310
      // Select the first file in the list.
311
      if( i == activeIndex ) {
312
        getSelectionModel().select( fileEditorTab );
313
      }
314
    }
315
  }
316
317
  /**
318
   * Returns a property that changes when a new definition file is opened.
319
   *
320
   * @return The path to a definition file that was opened.
321
   */
322
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
323
    return getOnOpenDefinitionFile().getReadOnlyProperty();
324
  }
325
326
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
327
    return this.openDefinition;
328
  }
329
330
  /**
331
   * Called when the user has opened a definition file (using the file open
332
   * dialog box). This will replace the current set of definitions for the
333
   * active tab.
334
   *
335
   * @param definition The file to open.
336
   */
337
  private void openDefinition( final File definition ) {
338
    // TODO: Prevent reading this file twice when a new text document is opened.
339
    // (might be a matter of checking the value first).
340
    getOnOpenDefinitionFile().set( definition.toPath() );
341
  }
342
343
  /**
344
   * Called when the contents of the editor are to be saved.
345
   *
346
   * @param tab The tab containing content to save.
347
   * @return true The contents were saved (or needn't be saved).
348
   */
349
  public boolean saveEditor( final FileEditorTab tab ) {
350
    if( tab == null || !tab.isModified() ) {
351
      return true;
352
    }
353
354
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
355
  }
356
357
  /**
358
   * Opens the Save As dialog for the user to save the content under a new
359
   * path.
360
   *
361
   * @param tab The tab with contents to save.
362
   * @return true The contents were saved, or the tab was null.
363
   */
364
  public boolean saveEditorAs( final FileEditorTab tab ) {
365
    if( tab == null ) {
366
      return true;
367
    }
368
369
    getSelectionModel().select( tab );
370
371
    final FileChooser fileChooser = createFileChooser( get(
372
        "Dialog.file.choose.save.title" ) );
373
    final File file = fileChooser.showSaveDialog( getWindow() );
374
    if( file == null ) {
375
      return false;
376
    }
377
378
    saveLastDirectory( file );
379
    tab.setPath( file.toPath() );
380
381
    return tab.save();
382
  }
383
384
  void saveAllEditors() {
385
    for( final FileEditorTab fileEditor : getAllEditors() ) {
386
      saveEditor( fileEditor );
387
    }
388
  }
389
390
  /**
391
   * Answers whether the file has had modifications. '
392
   *
393
   * @param tab THe tab to check for modifications.
394
   * @return false The file is unmodified.
395
   */
396
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
397
  boolean canCloseEditor( final FileEditorTab tab ) {
398
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
399
    canClose.set( true );
400
401
    if( tab.isModified() ) {
402
      final Notification message = getNotifyService().createNotification(
403
          Messages.get( "Alert.file.close.title" ),
404
          Messages.get( "Alert.file.close.text" ),
405
          tab.getText()
406
      );
407
408
      final Alert confirmSave = getNotifyService().createConfirmation(
409
          getWindow(), message );
410
411
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
412
413
      buttonType.ifPresent(
414
          save -> canClose.set(
415
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
416
          )
417
      );
418
    }
419
420
    return canClose.get();
421
  }
422
423
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
424
    if( tab == null ) {
425
      return true;
426
    }
427
428
    if( save ) {
429
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
430
      Event.fireEvent( tab, event );
431
432
      if( event.isConsumed() ) {
433
        return false;
434
      }
435
    }
436
437
    getTabs().remove( tab );
438
439
    if( tab.getOnClosed() != null ) {
440
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
441
    }
442
443
    return true;
444
  }
445
446
  boolean closeAllEditors() {
447
    final FileEditorTab[] allEditors = getAllEditors();
448
    final FileEditorTab activeEditor = getActiveFileEditor();
449
450
    // try to save active tab first because in case the user decides to cancel,
451
    // then it stays active
452
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
453
      return false;
454
    }
455
456
    // This should be called any time a tab changes.
457
    persistPreferences();
458
459
    // save modified tabs
460
    for( int i = 0; i < allEditors.length; i++ ) {
461
      final FileEditorTab fileEditor = allEditors[ i ];
462
463
      if( fileEditor == activeEditor ) {
464
        continue;
465
      }
466
467
      if( fileEditor.isModified() ) {
468
        // activate the modified tab to make its modified content visible to
469
        // the user
470
        getSelectionModel().select( i );
471
472
        if( !canCloseEditor( fileEditor ) ) {
473
          return false;
474
        }
475
      }
476
    }
477
478
    // Close all tabs.
479
    for( final FileEditorTab fileEditor : allEditors ) {
480
      if( !closeEditor( fileEditor, false ) ) {
481
        return false;
482
      }
483
    }
484
485
    return getTabs().isEmpty();
486
  }
487
488
  private FileEditorTab[] getAllEditors() {
489
    final ObservableList<Tab> tabs = getTabs();
490
    final int length = tabs.size();
491
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
492
493
    for( int i = 0; i < length; i++ ) {
494
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
495
    }
496
497
    return allEditors;
498
  }
499
500
  /**
501
   * Returns the file editor tab that has the given path.
502
   *
503
   * @return null No file editor tab for the given path was found.
504
   */
505
  private FileEditorTab findEditor( final Path path ) {
506
    for( final Tab tab : getTabs() ) {
507
      final FileEditorTab fileEditor = (FileEditorTab) tab;
508
509
      if( fileEditor.isPath( path ) ) {
510
        return fileEditor;
511
      }
512
    }
513
514
    return null;
515
  }
516
517
  private FileChooser createFileChooser( String title ) {
518
    final FileChooser fileChooser = new FileChooser();
519
520
    fileChooser.setTitle( title );
521
    fileChooser.getExtensionFilters().addAll(
522
        createExtensionFilters() );
523
524
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
525
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
526
527
    if( !file.isDirectory() ) {
528
      file = new File( "." );
529
    }
530
531
    fileChooser.setInitialDirectory( file );
532
    return fileChooser;
533
  }
534
535
  private List<ExtensionFilter> createExtensionFilters() {
536
    final List<ExtensionFilter> list = new ArrayList<>();
537
538
    // TODO: Return a list of all properties that match the filter prefix.
539
    // This will allow dynamic filters to be added and removed just by
540
    // updating the properties file.
541
    list.add( createExtensionFilter( ALL ) );
542
    list.add( createExtensionFilter( SOURCE ) );
543
    list.add( createExtensionFilter( DEFINITION ) );
544
    list.add( createExtensionFilter( XML ) );
545
    return list;
546
  }
547
548
  /**
549
   * Returns a filter for file name extensions recognized by the application
550
   * that can be opened by the user.
551
   *
552
   * @param filetype Used to find the globbing pattern for extensions.
553
   * @return A filename filter suitable for use by a FileDialog instance.
554
   */
555
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
556
    final String tKey = String.format( "%s.title.%s",
557
                                       FILTER_EXTENSION_TITLES,
558
                                       filetype );
559
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
560
561
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
562
  }
563
564
  private List<String> getExtensions( final String key ) {
565
    return getSettings().getStringSettingList( key );
566
  }
567
568
  private void saveLastDirectory( final File file ) {
569
    getPreferences().put( "lastDirectory", file.getParent() );
570
  }
571
572
  public void restorePreferences() {
573
    int activeIndex = 0;
574
575
    final Preferences preferences = getPreferences();
576
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
577
    final String activeFileName = preferences.get( "activeFile", null );
578
579
    final List<File> files = new ArrayList<>( fileNames.length );
580
581
    for( final String fileName : fileNames ) {
582
      final File file = new File( fileName );
583
584
      if( file.exists() ) {
585
        files.add( file );
586
587
        if( fileName.equals( activeFileName ) ) {
588
          activeIndex = files.size() - 1;
589
        }
590
      }
591
    }
592
593
    if( files.isEmpty() ) {
594
      newEditor();
595
    }
596
    else {
597
      openEditors( files, activeIndex );
598
    }
599
  }
600
601
  public void persistPreferences() {
602
    final ObservableList<Tab> allEditors = getTabs();
603
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
604
605
    for( final Tab tab : allEditors ) {
606
      final FileEditorTab fileEditor = (FileEditorTab) tab;
607
      final Path filePath = fileEditor.getPath();
608
609
      if( filePath != null ) {
610
        fileNames.add( filePath.toString() );
611
      }
612
    }
613
614
    final Preferences preferences = getPreferences();
615
    Utils.putPrefsStrings( preferences,
616
                           "file",
617
                           fileNames.toArray( new String[ 0 ] ) );
618
619
    final FileEditorTab activeEditor = getActiveFileEditor();
620
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
621
622
    if( filePath == null ) {
623
      preferences.remove( "activeFile" );
624
    }
625
    else {
626
      preferences.put( "activeFile", filePath.toString() );
627
    }
628
  }
629
630
  private Notifier getNotifyService() {
631
    return sNotifier;
632
  }
633
634
  private Settings getSettings() {
635
    return sSettings;
636
  }
637
638
  protected Options getOptions() {
639
    return sOptions;
632640
  }
633641
M src/main/java/com/scrivenvar/Launcher.java
5050
   */
5151
  public static void main( final String[] args ) throws IOException {
52
    // Shhh.
53
    System.err.close();
54
5255
    showAppInfo();
5356
    Main.main( args );
5457
  }
5558
59
  @SuppressWarnings("RedundantStringFormatCall")
5660
  private static void showAppInfo() throws IOException {
5761
    out( format( "%s version %s", getTitle(), getVersion() ) );
M src/main/java/com/scrivenvar/MainWindow.java
3333
import com.scrivenvar.definition.MapInterpolator;
3434
import com.scrivenvar.definition.yaml.YamlDefinitionSource;
35
import com.scrivenvar.dialogs.RScriptDialog;
36
import com.scrivenvar.editors.EditorPane;
37
import com.scrivenvar.editors.VariableNameInjector;
38
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
39
import com.scrivenvar.preview.HTMLPreviewPane;
40
import com.scrivenvar.processors.Processor;
41
import com.scrivenvar.processors.ProcessorFactory;
42
import com.scrivenvar.service.Options;
43
import com.scrivenvar.service.Settings;
44
import com.scrivenvar.service.Snitch;
45
import com.scrivenvar.service.events.Notifier;
46
import com.scrivenvar.util.Action;
47
import com.scrivenvar.util.ActionUtils;
48
import javafx.application.Platform;
49
import javafx.beans.binding.Bindings;
50
import javafx.beans.binding.BooleanBinding;
51
import javafx.beans.property.BooleanProperty;
52
import javafx.beans.property.SimpleBooleanProperty;
53
import javafx.beans.value.ObservableBooleanValue;
54
import javafx.beans.value.ObservableValue;
55
import javafx.collections.ListChangeListener.Change;
56
import javafx.collections.ObservableList;
57
import javafx.event.Event;
58
import javafx.event.EventHandler;
59
import javafx.geometry.Pos;
60
import javafx.scene.Node;
61
import javafx.scene.Scene;
62
import javafx.scene.control.*;
63
import javafx.scene.control.Alert.AlertType;
64
import javafx.scene.image.Image;
65
import javafx.scene.image.ImageView;
66
import javafx.scene.input.KeyEvent;
67
import javafx.scene.layout.BorderPane;
68
import javafx.scene.layout.VBox;
69
import javafx.scene.text.Text;
70
import javafx.stage.Window;
71
import javafx.stage.WindowEvent;
72
import javafx.util.Duration;
73
import org.controlsfx.control.StatusBar;
74
import org.fxmisc.richtext.model.TwoDimensional.Position;
75
76
import java.io.File;
77
import java.nio.file.Path;
78
import java.util.*;
79
import java.util.function.Function;
80
import java.util.prefs.Preferences;
81
82
import static com.scrivenvar.Constants.*;
83
import static com.scrivenvar.Messages.get;
84
import static com.scrivenvar.Messages.getLiteral;
85
import static com.scrivenvar.util.StageState.*;
86
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
87
import static javafx.event.Event.fireEvent;
88
import static javafx.scene.input.KeyCode.ENTER;
89
import static javafx.scene.input.KeyCode.TAB;
90
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
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 mOptions = Services.load( Options.class );
100
  private final Snitch mSnitch = Services.load( Snitch.class );
101
  private final Settings mSettings = Services.load( Settings.class );
102
  private final Notifier mNotifier = Services.load( Notifier.class );
103
104
  private final Scene mScene;
105
  private final StatusBar mStatusBar;
106
  private final Text mLineNumberText;
107
  private final TextField mFindTextField;
108
109
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
110
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
111
  private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane();
112
  private FileEditorTabPane fileEditorPane;
113
114
  /**
115
   * Prevents re-instantiation of processing classes.
116
   */
117
  private final Map<FileEditorTab, Processor<String>> mProcessors =
118
      new HashMap<>();
119
120
  private final Map<String, String> mResolvedMap =
121
      new HashMap<>( DEFAULT_MAP_SIZE );
122
123
  /**
124
   * Listens on the definition pane for double-click events.
125
   */
126
  private VariableNameInjector variableNameInjector;
127
128
  /**
129
   * Called when the definition data is changed.
130
   */
131
  final EventHandler<TreeItem.TreeModificationEvent<Event>> mTreeHandler =
132
      event -> {
133
        exportDefinitions( getDefinitionPath() );
134
        interpolateResolvedMap();
135
        refreshActiveTab();
136
      };
137
138
  final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
139
      event -> {
140
        if( event.getCode() == ENTER ) {
141
          getVariableNameInjector().injectSelectedItem();
142
        }
143
      };
144
145
  final EventHandler<? super KeyEvent> mEditorKeyHandler =
146
      (EventHandler<KeyEvent>) event -> {
147
        if( event.getCode() == TAB ) {
148
          getDefinitionPane().requestFocus();
149
          event.consume();
150
        }
151
      };
152
153
  public MainWindow() {
154
    mStatusBar = createStatusBar();
155
    mLineNumberText = createLineNumberText();
156
    mFindTextField = createFindTextField();
157
    mScene = createScene();
158
159
    initLayout();
160
    initFindInput();
161
    initSnitch();
162
    initDefinitionListener();
163
    initTabAddedListener();
164
    initTabChangedListener();
165
    initPreferences();
166
  }
167
168
  /**
169
   * Watch for changes to external files. In particular, this awaits
170
   * modifications to any XSL files associated with XML files being edited. When
171
   * an XSL file is modified (external to the application), the snitch's ears
172
   * perk up and the file is reloaded. This keeps the XSL transformation up to
173
   * date with what's on the file system.
174
   */
175
  private void initSnitch() {
176
    getSnitch().addObserver( this );
177
  }
178
179
  /**
180
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
181
   * presses.
182
   */
183
  private void initFindInput() {
184
    final TextField input = getFindTextField();
185
186
    input.setOnKeyPressed( ( KeyEvent event ) -> {
187
      switch( event.getCode() ) {
188
        case F3:
189
        case ENTER:
190
          findNext();
191
          break;
192
        case F:
193
          if( !event.isControlDown() ) {
194
            break;
195
          }
196
        case ESCAPE:
197
          getStatusBar().setGraphic( null );
198
          getActiveFileEditor().getEditorPane().requestFocus();
199
          break;
200
      }
201
    } );
202
203
    // Remove when the input field loses focus.
204
    input.focusedProperty().addListener(
205
        (
206
            final ObservableValue<? extends Boolean> focused,
207
            final Boolean oFocus,
208
            final Boolean nFocus ) -> {
209
          if( !nFocus ) {
210
            getStatusBar().setGraphic( null );
211
          }
212
        }
213
    );
214
  }
215
216
  /**
217
   * Listen for {@link FileEditorTabPane} to receive open definition file event.
218
   */
219
  private void initDefinitionListener() {
220
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
221
        ( final ObservableValue<? extends Path> file,
222
          final Path oldPath, final Path newPath ) -> {
223
          // Indirectly refresh the resolved map.
224
          resetProcessors();
225
226
          openDefinitions( newPath );
227
228
          // Will create new processors and therefore a new resolved map.
229
          refreshActiveTab();
230
        }
231
    );
232
  }
233
234
  /**
235
   * When tabs are added, hook the various change listeners onto the new tab so
236
   * that the preview pane refreshes as necessary.
237
   */
238
  private void initTabAddedListener() {
239
    final FileEditorTabPane editorPane = getFileEditorPane();
240
241
    // Make sure the text processor kicks off when new files are opened.
242
    final ObservableList<Tab> tabs = editorPane.getTabs();
243
244
    // Update the preview pane on tab changes.
245
    tabs.addListener(
246
        ( final Change<? extends Tab> change ) -> {
247
          while( change.next() ) {
248
            if( change.wasAdded() ) {
249
              // Multiple tabs can be added simultaneously.
250
              for( final Tab newTab : change.getAddedSubList() ) {
251
                final FileEditorTab tab = (FileEditorTab) newTab;
252
253
                initTextChangeListener( tab );
254
                initCaretParagraphListener( tab );
255
                initKeyboardEventListeners( tab );
256
//              initSyntaxListener( tab );
257
              }
258
            }
259
          }
260
        }
261
    );
262
  }
263
264
  /**
265
   * Reloads the preferences from the previous session.
266
   */
267
  private void initPreferences() {
268
    restoreDefinitionPane();
269
    getFileEditorPane().restorePreferences();
270
  }
271
272
  /**
273
   * Listen for new tab selection events.
274
   */
275
  private void initTabChangedListener() {
276
    final FileEditorTabPane editorPane = getFileEditorPane();
277
278
    // Update the preview pane changing tabs.
279
    editorPane.addTabSelectionListener(
280
        ( ObservableValue<? extends Tab> tabPane,
281
          final Tab oldTab, final Tab newTab ) -> {
282
          updateVariableNameInjector();
283
284
          // If there was no old tab, then this is a first time load, which
285
          // can be ignored.
286
          if( oldTab != null ) {
287
            if( newTab == null ) {
288
              closeRemainingTab();
289
            }
290
            else {
291
              // Update the preview with the edited text.
292
              refreshSelectedTab( (FileEditorTab) newTab );
293
            }
294
          }
295
        }
296
    );
297
  }
298
299
  /**
300
   * Ensure that the keyboard events are received when a new tab is added
301
   * to the user interface.
302
   *
303
   * @param tab The tab that can trigger keyboard events, such as control+space.
304
   */
305
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
306
    final VariableNameInjector vin = getVariableNameInjector();
307
    vin.initKeyboardEventListeners( tab );
308
309
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler );
310
  }
311
312
  private void initTextChangeListener( final FileEditorTab tab ) {
313
    tab.addTextChangeListener(
314
        ( ObservableValue<? extends String> editor,
315
          final String oldValue, final String newValue ) ->
316
            refreshSelectedTab( tab )
317
    );
318
  }
319
320
  private void initCaretParagraphListener( final FileEditorTab tab ) {
321
    tab.addCaretParagraphListener(
322
        ( ObservableValue<? extends Integer> editor,
323
          final Integer oldValue, final Integer newValue ) ->
324
            refreshSelectedTab( tab )
325
    );
326
  }
327
328
  private void updateVariableNameInjector() {
329
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
330
  }
331
332
  private void setVariableNameInjector( final VariableNameInjector injector ) {
333
    this.variableNameInjector = injector;
334
  }
335
336
  private synchronized VariableNameInjector getVariableNameInjector() {
337
    if( this.variableNameInjector == null ) {
338
      final VariableNameInjector vin = createVariableNameInjector();
339
      setVariableNameInjector( vin );
340
    }
341
342
    return this.variableNameInjector;
343
  }
344
345
  private VariableNameInjector createVariableNameInjector() {
346
    final FileEditorTab tab = getActiveFileEditor();
347
    final DefinitionPane pane = getDefinitionPane();
348
349
    return new VariableNameInjector( tab, pane );
350
  }
351
352
  /**
353
   * Called whenever the preview pane becomes out of sync with the file editor
354
   * tab. This can be called when the text changes, the caret paragraph changes,
355
   * or the file tab changes.
356
   *
357
   * @param tab The file editor tab that has been changed in some fashion.
358
   */
359
  private void refreshSelectedTab( final FileEditorTab tab ) {
360
    if( tab == null ) {
361
      return;
362
    }
363
364
    getPreviewPane().setPath( tab.getPath() );
365
366
    // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
367
    final Position p = tab.getCaretOffset();
368
    getLineNumberText().setText(
369
        get( STATUS_BAR_LINE,
370
             p.getMajor() + 1,
371
             p.getMinor() + 1,
372
             tab.getCaretPosition() + 1
373
        )
374
    );
375
376
    Processor<String> processor = getProcessors().get( tab );
377
378
    if( processor == null ) {
379
      processor = createProcessor( tab );
380
      getProcessors().put( tab, processor );
381
    }
382
383
    try {
384
      processor.processChain( tab.getEditorText() );
385
    } catch( final Exception ex ) {
386
      error( ex );
387
    }
388
  }
389
390
  private void refreshActiveTab() {
391
    refreshSelectedTab( getActiveFileEditor() );
392
  }
393
394
  /**
395
   * Used to find text in the active file editor window.
396
   */
397
  private void find() {
398
    final TextField input = getFindTextField();
399
    getStatusBar().setGraphic( input );
400
    input.requestFocus();
401
  }
402
403
  public void findNext() {
404
    getActiveFileEditor().searchNext( getFindTextField().getText() );
405
  }
406
407
  /**
408
   * Returns the variable map of interpolated definitions.
409
   *
410
   * @return A map to help dereference variables.
411
   */
412
  private Map<String, String> getResolvedMap() {
413
    return mResolvedMap;
414
  }
415
416
  private void interpolateResolvedMap() {
417
    final Map<String, String> treeMap = getDefinitionPane().toMap();
418
    final Map<String, String> map = new HashMap<>( treeMap );
419
    MapInterpolator.interpolate( map );
420
421
    getResolvedMap().clear();
422
    getResolvedMap().putAll( map );
423
  }
424
425
  /**
426
   * Called when a definition source is opened.
427
   *
428
   * @param path Path to the definition source that was opened.
429
   */
430
  private void openDefinitions( final Path path ) {
431
    try {
432
      final DefinitionSource ds = createDefinitionSource( path );
433
      setDefinitionSource( ds );
434
      storeDefinitionSourceFilename( path );
435
436
      final Tooltip tooltipPath = new Tooltip( path.toString() );
437
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
438
439
      final DefinitionPane pane = getDefinitionPane();
440
      pane.update( ds );
441
      pane.addTreeChangeHandler( mTreeHandler );
442
      pane.addKeyEventHandler( mDefinitionKeyHandler );
443
      pane.filenameProperty().setValue( path.getFileName().toString() );
444
      pane.setTooltip( tooltipPath );
445
446
      interpolateResolvedMap();
447
    } catch( final Exception e ) {
448
      error( e );
449
    }
450
  }
451
452
  private void exportDefinitions( final Path path ) {
453
    try {
454
      final DefinitionPane pane = getDefinitionPane();
455
      final TreeItem<String> root = pane.getTreeView().getRoot();
456
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
457
458
      if( problemChild == null ) {
459
        getDefinitionSource().getTreeAdapter().export( root, path );
460
        getNotifier().clear();
461
      }
462
      else {
463
        final String msg = get( "yaml.error.tree.form",
464
                                problemChild.getValue() );
465
        getNotifier().notify( msg );
466
      }
467
    } catch( final Exception e ) {
468
      error( e );
469
    }
470
  }
471
472
  private Path getDefinitionPath() {
473
    final String source = getPreferences().get(
474
        PERSIST_DEFINITION_SOURCE, "" );
475
476
    return new File(
477
        source.isBlank()
478
            ? getSetting( "file.definition.default", "variables.yaml" )
479
            : source
480
    ).toPath();
481
  }
482
483
  private void restoreDefinitionPane() {
484
    openDefinitions( getDefinitionPath() );
485
  }
486
487
  private void storeDefinitionSourceFilename( final Path path ) {
488
    getPreferences().put( PERSIST_DEFINITION_SOURCE, path.toString() );
489
  }
490
491
  /**
492
   * Called when the last open tab is closed to clear the preview pane.
493
   */
494
  private void closeRemainingTab() {
495
    getPreviewPane().clear();
496
  }
497
498
  /**
499
   * Called when an exception occurs that warrants the user's attention.
500
   *
501
   * @param e The exception with a message that the user should know about.
502
   */
503
  private void error( final Exception e ) {
504
    getNotifier().notify( e );
505
  }
506
507
  //---- File actions -------------------------------------------------------
508
509
  /**
510
   * Called when an observable instance has changed. This is called by both the
511
   * snitch service and the notify service. The snitch service can be called for
512
   * different file types, including definition sources.
513
   *
514
   * @param observable The observed instance.
515
   * @param value      The noteworthy item.
516
   */
517
  @Override
518
  public void update( final Observable observable, final Object value ) {
519
    if( value != null ) {
520
      if( observable instanceof Snitch && value instanceof Path ) {
521
        updateSelectedTab();
522
      }
523
      else if( observable instanceof Notifier && value instanceof String ) {
524
        updateStatusBar( (String) value );
525
      }
526
    }
527
  }
528
529
  /**
530
   * Updates the status bar to show the given message.
531
   *
532
   * @param s The message to show in the status bar.
533
   */
534
  private void updateStatusBar( final String s ) {
535
    Platform.runLater(
536
        () -> {
537
          final int index = s.indexOf( '\n' );
538
          final String message = s.substring(
539
              0, index > 0 ? index : s.length() );
540
541
          getStatusBar().setText( message );
542
        }
543
    );
544
  }
545
546
  /**
547
   * Called when a file has been modified.
548
   */
549
  private void updateSelectedTab() {
550
    Platform.runLater(
551
        () -> {
552
          // Brute-force XSLT file reload by re-instantiating all processors.
553
          resetProcessors();
554
          refreshActiveTab();
555
        }
556
    );
557
  }
558
559
  /**
560
   * After resetting the processors, they will refresh anew to be up-to-date
561
   * with the files (text and definition) currently loaded into the editor.
562
   */
563
  private void resetProcessors() {
564
    getProcessors().clear();
565
  }
566
567
  //---- File actions -------------------------------------------------------
568
  private void fileNew() {
569
    getFileEditorPane().newEditor();
570
  }
571
572
  private void fileOpen() {
573
    getFileEditorPane().openFileDialog();
574
  }
575
576
  private void fileClose() {
577
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
578
  }
579
580
  private void fileCloseAll() {
581
    getFileEditorPane().closeAllEditors();
582
  }
583
584
  private void fileSave() {
585
    getFileEditorPane().saveEditor( getActiveFileEditor() );
586
  }
587
588
  private void fileSaveAs() {
589
    final FileEditorTab editor = getActiveFileEditor();
590
    getFileEditorPane().saveEditorAs( editor );
591
    getProcessors().remove( editor );
592
593
    try {
594
      refreshSelectedTab( editor );
595
    } catch( final Exception ex ) {
596
      getNotifier().notify( ex );
597
    }
598
  }
599
600
  private void fileSaveAll() {
601
    getFileEditorPane().saveAllEditors();
602
  }
603
604
  private void fileExit() {
605
    final Window window = getWindow();
606
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
607
  }
608
609
  //---- R menu actions
610
  private void rScript() {
611
    final String script = getPreferences().get( PERSIST_R_STARTUP, "" );
612
    final RScriptDialog dialog = new RScriptDialog(
613
        getWindow(), "Dialog.r.script.title", script );
614
    final Optional<String> result = dialog.showAndWait();
615
616
    result.ifPresent( this::putStartupScript );
617
  }
618
619
  private void rDirectory() {
620
    final TextInputDialog dialog = new TextInputDialog(
621
        getPreferences().get( PERSIST_R_DIRECTORY, USER_DIRECTORY )
622
    );
623
624
    dialog.setTitle( get( "Dialog.r.directory.title" ) );
625
    dialog.setHeaderText( getLiteral( "Dialog.r.directory.header" ) );
626
    dialog.setContentText( "Directory" );
627
628
    final Optional<String> result = dialog.showAndWait();
629
630
    result.ifPresent( this::putStartupDirectory );
631
  }
632
633
  /**
634
   * Stores the R startup script into the user preferences.
635
   */
636
  private void putStartupScript( final String script ) {
637
    putPreference( PERSIST_R_STARTUP, script );
638
  }
639
640
  /**
641
   * Stores the R bootstrap script directory into the user preferences.
642
   */
643
  private void putStartupDirectory( final String directory ) {
644
    putPreference( PERSIST_R_DIRECTORY, directory );
645
  }
646
647
  //---- Help actions -------------------------------------------------------
648
  private void helpAbout() {
649
    final Alert alert = new Alert( AlertType.INFORMATION );
650
    alert.setTitle( get( "Dialog.about.title" ) );
651
    alert.setHeaderText( get( "Dialog.about.header" ) );
652
    alert.setContentText( get( "Dialog.about.content" ) );
653
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
654
    alert.initOwner( getWindow() );
655
656
    alert.showAndWait();
657
  }
658
659
  //---- Convenience accessors ----------------------------------------------
660
  private float getFloat( final String key, final float defaultValue ) {
661
    return getPreferences().getFloat( key, defaultValue );
662
  }
663
664
  private Preferences getPreferences() {
665
    return getOptions().getState();
666
  }
667
668
  protected Scene getScene() {
669
    return mScene;
670
  }
671
672
  public Window getWindow() {
673
    return getScene().getWindow();
674
  }
675
676
  private MarkdownEditorPane getActiveEditor() {
677
    final EditorPane pane = getActiveFileEditor().getEditorPane();
678
679
    return pane instanceof MarkdownEditorPane
680
        ? (MarkdownEditorPane) pane
681
        : null;
682
  }
683
684
  private FileEditorTab getActiveFileEditor() {
685
    return getFileEditorPane().getActiveFileEditor();
686
  }
687
688
  //---- Member accessors ---------------------------------------------------
689
690
  private Map<FileEditorTab, Processor<String>> getProcessors() {
691
    return mProcessors;
692
  }
693
694
  private FileEditorTabPane getFileEditorPane() {
695
    if( this.fileEditorPane == null ) {
696
      this.fileEditorPane = createFileEditorPane();
697
    }
698
699
    return this.fileEditorPane;
700
  }
701
702
  private HTMLPreviewPane getPreviewPane() {
703
    return mPreviewPane;
704
  }
705
706
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
707
    assert definitionSource != null;
708
    mDefinitionSource = definitionSource;
709
  }
710
711
  private DefinitionSource getDefinitionSource() {
712
    return mDefinitionSource;
713
  }
714
715
  private DefinitionPane getDefinitionPane() {
716
    return mDefinitionPane;
717
  }
718
719
  private Options getOptions() {
720
    return mOptions;
721
  }
722
723
  private Snitch getSnitch() {
724
    return mSnitch;
725
  }
726
727
  private Notifier getNotifier() {
728
    return mNotifier;
729
  }
730
731
  private Text getLineNumberText() {
732
    return mLineNumberText;
733
  }
734
735
  private StatusBar getStatusBar() {
736
    return mStatusBar;
737
  }
738
739
  private TextField getFindTextField() {
740
    return mFindTextField;
741
  }
742
743
  //---- Member creators ----------------------------------------------------
744
745
  /**
746
   * Factory to create processors that are suited to different file types.
747
   *
748
   * @param tab The tab that is subjected to processing.
749
   * @return A processor suited to the file type specified by the tab's path.
750
   */
751
  private Processor<String> createProcessor( final FileEditorTab tab ) {
752
    return createProcessorFactory().createProcessor( tab );
753
  }
754
755
  private ProcessorFactory createProcessorFactory() {
756
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
757
  }
758
759
  private DefinitionSource createDefaultDefinitionSource() {
760
    return new YamlDefinitionSource( getDefinitionPath() );
761
  }
762
763
  private DefinitionSource createDefinitionSource( final Path path ) {
764
    try {
765
      return createDefinitionFactory().createDefinitionSource( path );
766
    } catch( final Exception ex ) {
767
      error( ex );
768
      return createDefaultDefinitionSource();
769
    }
770
  }
771
772
  private TextField createFindTextField() {
773
    return new TextField();
774
  }
775
776
  /**
777
   * Create an editor pane to hold file editor tabs.
778
   *
779
   * @return A new instance, never null.
780
   */
781
  private FileEditorTabPane createFileEditorPane() {
782
    return new FileEditorTabPane();
783
  }
784
785
  private DefinitionFactory createDefinitionFactory() {
786
    return new DefinitionFactory();
787
  }
788
789
  private StatusBar createStatusBar() {
790
    return new StatusBar();
791
  }
792
793
  private Scene createScene() {
794
    final SplitPane splitPane = new SplitPane(
795
        getDefinitionPane().getNode(),
796
        getFileEditorPane().getNode(),
797
        getPreviewPane().getNode() );
798
799
    splitPane.setDividerPositions(
800
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
801
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
802
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
803
804
    getDefinitionPane().prefHeightProperty().bind( splitPane.heightProperty() );
805
806
    final BorderPane borderPane = new BorderPane();
807
    borderPane.setPrefSize( 1024, 800 );
808
    borderPane.setTop( createMenuBar() );
809
    borderPane.setBottom( getStatusBar() );
810
    borderPane.setCenter( splitPane );
811
812
    final VBox statusBar = new VBox();
813
    statusBar.setAlignment( Pos.BASELINE_CENTER );
814
    statusBar.getChildren().add( getLineNumberText() );
815
    getStatusBar().getRightItems().add( statusBar );
816
817
    return new Scene( borderPane );
818
  }
819
820
  private Text createLineNumberText() {
821
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
822
  }
823
824
  private Node createMenuBar() {
825
    final BooleanBinding activeFileEditorIsNull =
826
        getFileEditorPane().activeFileEditorProperty()
827
                           .isNull();
828
829
    // File actions
830
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ),
831
                                             "Shortcut+N", FILE_ALT,
832
                                             e -> fileNew() );
833
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ),
834
                                              "Shortcut+O", FOLDER_OPEN_ALT,
835
                                              e -> fileOpen() );
836
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ),
837
                                               "Shortcut+W", null,
838
                                               e -> fileClose(),
839
                                               activeFileEditorIsNull );
840
    final Action fileCloseAllAction = new Action( get(
841
        "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(),
842
                                                  activeFileEditorIsNull );
843
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ),
844
                                              "Shortcut+S", FLOPPY_ALT,
845
                                              e -> fileSave(),
846
                                              createActiveBooleanProperty(
847
                                                  FileEditorTab::modifiedProperty )
848
                                                  .not() );
849
    final Action fileSaveAsAction = new Action( Messages.get(
850
        "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(),
851
                                                activeFileEditorIsNull );
852
    final Action fileSaveAllAction = new Action(
853
        get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null,
854
        e -> fileSaveAll(),
855
        Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
856
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ),
857
                                              null,
858
                                              null,
859
                                              e -> fileExit() );
860
861
    // Edit actions
862
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ),
863
                                              "Shortcut+Z", UNDO,
864
                                              e -> getActiveEditor().undo(),
865
                                              createActiveBooleanProperty(
866
                                                  FileEditorTab::canUndoProperty )
867
                                                  .not() );
868
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ),
869
                                              "Shortcut+Y", REPEAT,
870
                                              e -> getActiveEditor().redo(),
871
                                              createActiveBooleanProperty(
872
                                                  FileEditorTab::canRedoProperty )
873
                                                  .not() );
874
    final Action editFindAction = new Action( Messages.get(
875
        "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
876
                                              e -> find(),
877
                                              activeFileEditorIsNull );
878
    final Action editFindNextAction = new Action( Messages.get(
879
        "Main.menu.edit.find.next" ), "F3", null,
880
                                                  e -> findNext(),
881
                                                  activeFileEditorIsNull );
882
883
    // Insert actions
884
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ),
885
                                                "Shortcut+B", BOLD,
886
                                                e -> getActiveEditor().surroundSelection(
887
                                                    "**", "**" ),
888
                                                activeFileEditorIsNull );
889
    final Action insertItalicAction = new Action(
890
        get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
891
        e -> getActiveEditor().surroundSelection( "*", "*" ),
892
        activeFileEditorIsNull );
893
    final Action insertSuperscriptAction = new Action( get(
894
        "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
895
                                                       e -> getActiveEditor().surroundSelection(
896
                                                           "^", "^" ),
897
                                                       activeFileEditorIsNull );
898
    final Action insertSubscriptAction = new Action( get(
899
        "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
900
                                                     e -> getActiveEditor().surroundSelection(
901
                                                         "~", "~" ),
902
                                                     activeFileEditorIsNull );
903
    final Action insertStrikethroughAction = new Action( get(
904
        "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
905
                                                         e -> getActiveEditor().surroundSelection(
906
                                                             "~~", "~~" ),
907
                                                         activeFileEditorIsNull );
908
    final Action insertBlockquoteAction = new Action( get(
909
        "Main.menu.insert.blockquote" ),
910
                                                      "Ctrl+Q",
911
                                                      QUOTE_LEFT,
912
                                                      // not Shortcut+Q
913
                                                      // because of conflict
914
                                                      // on Mac
915
                                                      e -> getActiveEditor().surroundSelection(
916
                                                          "\n\n> ", "" ),
917
                                                      activeFileEditorIsNull );
918
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ),
919
                                                "Shortcut+K", CODE,
920
                                                e -> getActiveEditor().surroundSelection(
921
                                                    "`", "`" ),
922
                                                activeFileEditorIsNull );
923
    final Action insertFencedCodeBlockAction = new Action( get(
924
        "Main.menu.insert.fenced_code_block" ),
925
                                                           "Shortcut+Shift+K",
926
                                                           FILE_CODE_ALT,
927
                                                           e -> getActiveEditor()
928
                                                               .surroundSelection(
929
                                                                   "\n\n```\n",
930
                                                                   "\n```\n\n",
931
                                                                   get(
932
                                                                       "Main.menu.insert.fenced_code_block.prompt" ) ),
933
                                                           activeFileEditorIsNull );
934
935
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ),
936
                                                "Shortcut+L", LINK,
937
                                                e -> getActiveEditor().insertLink(),
938
                                                activeFileEditorIsNull );
939
    final Action insertImageAction = new Action( get( "Main.menu.insert" +
940
                                                          ".image" ),
941
                                                 "Shortcut+G", PICTURE_ALT,
942
                                                 e -> getActiveEditor().insertImage(),
943
                                                 activeFileEditorIsNull );
944
945
    // Number of header actions (H1 ... H3)
946
    final int HEADERS = 3;
947
    final Action[] headers = new Action[ HEADERS ];
948
949
    for( int i = 1; i <= HEADERS; i++ ) {
950
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
951
      final String markup = String.format( "%n%n%s ", hashes );
952
      final String text = get( "Main.menu.insert.header_" + i );
953
      final String accelerator = "Shortcut+" + i;
954
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
955
956
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
957
                                     e -> getActiveEditor().surroundSelection(
958
                                         markup, "", prompt ),
959
                                     activeFileEditorIsNull );
960
    }
961
962
    final Action insertUnorderedListAction = new Action(
963
        get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
964
        e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
965
        activeFileEditorIsNull );
966
    final Action insertOrderedListAction = new Action(
967
        get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
968
        e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
969
        activeFileEditorIsNull );
970
    final Action insertHorizontalRuleAction = new Action(
971
        get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
972
        e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
973
        activeFileEditorIsNull );
974
975
    // R actions
976
    final Action mRScriptAction = new Action(
977
        get( "Main.menu.r.script" ), null, null, e -> rScript() );
978
979
    final Action mRDirectoryAction = new Action(
980
        get( "Main.menu.r.directory" ), null, null, e -> rDirectory() );
981
982
    // Help actions
983
    final Action helpAboutAction = new Action(
984
        get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
985
986
    //---- MenuBar ----
987
    final Menu fileMenu = ActionUtils.createMenu(
988
        get( "Main.menu.file" ),
989
        fileNewAction,
990
        fileOpenAction,
991
        null,
992
        fileCloseAction,
993
        fileCloseAllAction,
994
        null,
995
        fileSaveAction,
996
        fileSaveAsAction,
997
        fileSaveAllAction,
998
        null,
999
        fileExitAction );
1000
1001
    final Menu editMenu = ActionUtils.createMenu(
1002
        get( "Main.menu.edit" ),
1003
        editUndoAction,
1004
        editRedoAction,
1005
        editFindAction,
1006
        editFindNextAction );
1007
1008
    final Menu insertMenu = ActionUtils.createMenu(
1009
        get( "Main.menu.insert" ),
1010
        insertBoldAction,
1011
        insertItalicAction,
1012
        insertSuperscriptAction,
1013
        insertSubscriptAction,
1014
        insertStrikethroughAction,
1015
        insertBlockquoteAction,
1016
        insertCodeAction,
1017
        insertFencedCodeBlockAction,
1018
        null,
1019
        insertLinkAction,
1020
        insertImageAction,
1021
        null,
1022
        headers[ 0 ],
1023
        headers[ 1 ],
1024
        headers[ 2 ],
1025
        null,
1026
        insertUnorderedListAction,
1027
        insertOrderedListAction,
1028
        insertHorizontalRuleAction );
1029
1030
    final Menu rMenu = ActionUtils.createMenu(
1031
        get( "Main.menu.r" ),
1032
        mRScriptAction,
1033
        mRDirectoryAction );
1034
1035
    final Menu helpMenu = ActionUtils.createMenu(
1036
        get( "Main.menu.help" ),
1037
        helpAboutAction );
1038
1039
    final MenuBar menuBar = new MenuBar(
1040
        fileMenu,
1041
        editMenu,
1042
        insertMenu,
1043
        rMenu,
1044
        helpMenu );
1045
1046
    //---- ToolBar ----
1047
    final ToolBar toolBar = ActionUtils.createToolBar(
1048
        fileNewAction,
1049
        fileOpenAction,
1050
        fileSaveAction,
1051
        null,
1052
        editUndoAction,
1053
        editRedoAction,
1054
        null,
1055
        insertBoldAction,
1056
        insertItalicAction,
1057
        insertSuperscriptAction,
1058
        insertSubscriptAction,
1059
        insertBlockquoteAction,
1060
        insertCodeAction,
1061
        insertFencedCodeBlockAction,
1062
        null,
1063
        insertLinkAction,
1064
        insertImageAction,
1065
        null,
1066
        headers[ 0 ],
1067
        null,
1068
        insertUnorderedListAction,
1069
        insertOrderedListAction );
1070
1071
    return new VBox( menuBar, toolBar );
1072
  }
1073
1074
  /**
1075
   * Creates a boolean property that is bound to another boolean value of the
1076
   * active editor.
1077
   */
1078
  private BooleanProperty createActiveBooleanProperty(
1079
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1080
1081
    final BooleanProperty b = new SimpleBooleanProperty();
1082
    final FileEditorTab tab = getActiveFileEditor();
1083
1084
    if( tab != null ) {
1085
      b.bind( func.apply( tab ) );
1086
    }
1087
1088
    getFileEditorPane().activeFileEditorProperty().addListener(
1089
        ( observable, oldFileEditor, newFileEditor ) -> {
1090
          b.unbind();
1091
1092
          if( newFileEditor != null ) {
1093
            b.bind( func.apply( newFileEditor ) );
1094
          }
1095
          else {
1096
            b.set( false );
1097
          }
1098
        }
1099
    );
1100
1101
    return b;
1102
  }
1103
1104
  private void initLayout() {
1105
    final Scene appScene = getScene();
1106
1107
    appScene.getStylesheets().add( STYLESHEET_SCENE );
1108
1109
    // TODO: Apply an XML syntax highlighting for XML files.
1110
//    appScene.getStylesheets().add( STYLESHEET_XML );
1111
    appScene.windowProperty().addListener(
1112
        ( observable, oldWindow, newWindow ) ->
1113
            newWindow.setOnCloseRequest(
1114
                e -> {
1115
                  if( !getFileEditorPane().closeAllEditors() ) {
1116
                    e.consume();
1117
                  }
1118
                }
1119
            )
1120
    );
1121
  }
1122
1123
  private void putPreference( final String key, final String value ) {
1124
    try {
1125
      getPreferences().put( key, value );
1126
    } catch( final Exception ex ) {
1127
      getNotifier().notify( ex );
1128
    }
1129
  }
1130
1131
  /**
1132
   * Returns the value for a key from the settings properties file.
1133
   *
1134
   * @param key   Key within the settings properties file to find.
1135
   * @param value Default value to return if the key is not found.
1136
   * @return The value for the given key from the settings file, or the
1137
   * given {@code value} if no key found.
1138
   */
1139
  @SuppressWarnings("SameParameterValue")
1140
  private String getSetting( final String key, final String value ) {
1141
    return mSettings.getSetting( key, value );
35
import com.scrivenvar.editors.EditorPane;
36
import com.scrivenvar.editors.VariableNameInjector;
37
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
38
import com.scrivenvar.preferences.UserPreferences;
39
import com.scrivenvar.preview.HTMLPreviewPane;
40
import com.scrivenvar.processors.Processor;
41
import com.scrivenvar.processors.ProcessorFactory;
42
import com.scrivenvar.service.Options;
43
import com.scrivenvar.service.Snitch;
44
import com.scrivenvar.service.events.Notifier;
45
import com.scrivenvar.util.Action;
46
import com.scrivenvar.util.ActionBuilder;
47
import com.scrivenvar.util.ActionUtils;
48
import javafx.application.Platform;
49
import javafx.beans.binding.Bindings;
50
import javafx.beans.binding.BooleanBinding;
51
import javafx.beans.property.BooleanProperty;
52
import javafx.beans.property.SimpleBooleanProperty;
53
import javafx.beans.value.ObservableBooleanValue;
54
import javafx.beans.value.ObservableValue;
55
import javafx.collections.ListChangeListener.Change;
56
import javafx.collections.ObservableList;
57
import javafx.event.Event;
58
import javafx.event.EventHandler;
59
import javafx.geometry.Pos;
60
import javafx.scene.Node;
61
import javafx.scene.Scene;
62
import javafx.scene.control.*;
63
import javafx.scene.control.Alert.AlertType;
64
import javafx.scene.image.Image;
65
import javafx.scene.image.ImageView;
66
import javafx.scene.input.KeyEvent;
67
import javafx.scene.layout.BorderPane;
68
import javafx.scene.layout.VBox;
69
import javafx.scene.text.Text;
70
import javafx.stage.Window;
71
import javafx.stage.WindowEvent;
72
import javafx.util.Duration;
73
import org.controlsfx.control.StatusBar;
74
import org.fxmisc.richtext.model.TwoDimensional.Position;
75
76
import java.io.File;
77
import java.nio.file.Path;
78
import java.util.HashMap;
79
import java.util.Map;
80
import java.util.Observable;
81
import java.util.Observer;
82
import java.util.concurrent.atomic.AtomicInteger;
83
import java.util.function.Consumer;
84
import java.util.function.Function;
85
import java.util.prefs.Preferences;
86
87
import static com.scrivenvar.Constants.*;
88
import static com.scrivenvar.Messages.get;
89
import static com.scrivenvar.util.StageState.*;
90
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
91
import static javafx.event.Event.fireEvent;
92
import static javafx.scene.input.KeyCode.ENTER;
93
import static javafx.scene.input.KeyCode.TAB;
94
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
95
96
/**
97
 * Main window containing a tab pane in the center for file editors.
98
 *
99
 * @author Karl Tauber and White Magic Software, Ltd.
100
 */
101
public class MainWindow implements Observer {
102
103
  /**
104
   * The {@code OPTIONS} variable must be declared before all other variables
105
   * to prevent subsequent initializations from failing due to missing user
106
   * preferences.
107
   */
108
  private final static Options OPTIONS = Services.load( Options.class );
109
  private final static Snitch SNITCH = Services.load( Snitch.class );
110
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
111
112
  private final Scene mScene;
113
  private final StatusBar mStatusBar;
114
  private final Text mLineNumberText;
115
  private final TextField mFindTextField;
116
117
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
118
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
119
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
120
  private FileEditorTabPane fileEditorPane;
121
122
  /**
123
   * Prevents re-instantiation of processing classes.
124
   */
125
  private final Map<FileEditorTab, Processor<String>> mProcessors =
126
      new HashMap<>();
127
128
  private final Map<String, String> mResolvedMap =
129
      new HashMap<>( DEFAULT_MAP_SIZE );
130
131
  /**
132
   * Listens on the definition pane for double-click events.
133
   */
134
  private VariableNameInjector variableNameInjector;
135
136
  /**
137
   * Called when the definition data is changed.
138
   */
139
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
140
      mTreeHandler = event -> {
141
    exportDefinitions( getDefinitionPath() );
142
    interpolateResolvedMap();
143
    refreshActiveTab();
144
  };
145
146
  /**
147
   * Called to inject the selected item when the user presses ENTER in the
148
   * definition pane.
149
   */
150
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
151
      event -> {
152
        if( event.getCode() == ENTER ) {
153
          getVariableNameInjector().injectSelectedItem();
154
        }
155
      };
156
157
  /**
158
   * Called to switch to the definition pane when the user presses TAB.
159
   */
160
  private final EventHandler<? super KeyEvent> mEditorKeyHandler =
161
      (EventHandler<KeyEvent>) event -> {
162
        if( event.getCode() == TAB ) {
163
          getDefinitionPane().requestFocus();
164
          event.consume();
165
        }
166
      };
167
168
  private final Object mMutex = new Object();
169
  private final AtomicInteger mScrollRatio = new AtomicInteger( 0 );
170
171
  /**
172
   * Called to synchronize the scrolling areas.
173
   */
174
  private final Consumer<Double> mScrollEventObserver = o -> {
175
    final var eScrollPane = getActiveEditor().getScrollPane();
176
    final int eScrollY =
177
        eScrollPane.estimatedScrollYProperty().getValue().intValue();
178
    final int eHeight = (int)
179
        (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
180
            - eScrollPane.getHeight());
181
    final double eRatio = eHeight > 0
182
        ? Math.min( Math.max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
183
184
    final var pPreviewPane = getPreviewPane();
185
    final var pScrollBar = pPreviewPane.getVerticalScrollBar();
186
    final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
187
    final var pScrollY = (int) (pHeight * eRatio);
188
    final var pScrollPane = pPreviewPane.getScrollPane();
189
190
    final int oldScrollY = mScrollRatio.getAndSet( pScrollY );
191
    final int delta = Math.abs( oldScrollY - pScrollY );
192
193
    if( delta > 33 ) {
194
      // Prevent concurrent modification exceptions when attempting to
195
      // set the vertical scroll bar position.
196
      synchronized( mMutex ) {
197
        Platform.runLater( () -> {
198
          pScrollBar.setValue( pScrollY );
199
          pScrollPane.repaint();
200
        } );
201
      }
202
    }
203
  };
204
205
  public MainWindow() {
206
    mStatusBar = createStatusBar();
207
    mLineNumberText = createLineNumberText();
208
    mFindTextField = createFindTextField();
209
    mScene = createScene();
210
211
    initLayout();
212
    initFindInput();
213
    initSnitch();
214
    initDefinitionListener();
215
    initTabAddedListener();
216
    initTabChangedListener();
217
    restorePreferences();
218
  }
219
220
  private void initLayout() {
221
    final Scene appScene = getScene();
222
223
    appScene.getStylesheets().add( STYLESHEET_SCENE );
224
225
    // TODO: Apply an XML syntax highlighting for XML files.
226
//    appScene.getStylesheets().add( STYLESHEET_XML );
227
    appScene.windowProperty().addListener(
228
        ( observable, oldWindow, newWindow ) ->
229
            newWindow.setOnCloseRequest(
230
                e -> {
231
                  if( !getFileEditorPane().closeAllEditors() ) {
232
                    e.consume();
233
                  }
234
                }
235
            )
236
    );
237
  }
238
239
  /**
240
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
241
   * presses.
242
   */
243
  private void initFindInput() {
244
    final TextField input = getFindTextField();
245
246
    input.setOnKeyPressed( ( KeyEvent event ) -> {
247
      switch( event.getCode() ) {
248
        case F3:
249
        case ENTER:
250
          editFindNext();
251
          break;
252
        case F:
253
          if( !event.isControlDown() ) {
254
            break;
255
          }
256
        case ESCAPE:
257
          getStatusBar().setGraphic( null );
258
          getActiveFileEditor().getEditorPane().requestFocus();
259
          break;
260
      }
261
    } );
262
263
    // Remove when the input field loses focus.
264
    input.focusedProperty().addListener(
265
        (
266
            final ObservableValue<? extends Boolean> focused,
267
            final Boolean oFocus,
268
            final Boolean nFocus ) -> {
269
          if( !nFocus ) {
270
            getStatusBar().setGraphic( null );
271
          }
272
        }
273
    );
274
  }
275
276
  /**
277
   * Watch for changes to external files. In particular, this awaits
278
   * modifications to any XSL files associated with XML files being edited. When
279
   * an XSL file is modified (external to the application), the snitch's ears
280
   * perk up and the file is reloaded. This keeps the XSL transformation up to
281
   * date with what's on the file system.
282
   */
283
  private void initSnitch() {
284
    SNITCH.addObserver( this );
285
  }
286
287
  /**
288
   * Listen for {@link FileEditorTabPane} to receive open definition file event.
289
   */
290
  private void initDefinitionListener() {
291
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
292
        ( final ObservableValue<? extends Path> file,
293
          final Path oldPath, final Path newPath ) -> {
294
          // Indirectly refresh the resolved map.
295
          resetProcessors();
296
297
          openDefinitions( newPath );
298
299
          // Will create new processors and therefore a new resolved map.
300
          refreshActiveTab();
301
        }
302
    );
303
  }
304
305
  /**
306
   * When tabs are added, hook the various change listeners onto the new tab so
307
   * that the preview pane refreshes as necessary.
308
   */
309
  private void initTabAddedListener() {
310
    final FileEditorTabPane editorPane = getFileEditorPane();
311
312
    // Make sure the text processor kicks off when new files are opened.
313
    final ObservableList<Tab> tabs = editorPane.getTabs();
314
315
    // Update the preview pane on tab changes.
316
    tabs.addListener(
317
        ( final Change<? extends Tab> change ) -> {
318
          while( change.next() ) {
319
            if( change.wasAdded() ) {
320
              // Multiple tabs can be added simultaneously.
321
              for( final Tab newTab : change.getAddedSubList() ) {
322
                final FileEditorTab tab = (FileEditorTab) newTab;
323
324
                initTextChangeListener( tab );
325
                initKeyboardEventListeners( tab );
326
//              initSyntaxListener( tab );
327
              }
328
            }
329
          }
330
        }
331
    );
332
  }
333
334
  /**
335
   * Listen for new tab selection events.
336
   */
337
  private void initTabChangedListener() {
338
    final FileEditorTabPane editorPane = getFileEditorPane();
339
340
    // Update the preview pane changing tabs.
341
    editorPane.addTabSelectionListener(
342
        ( ObservableValue<? extends Tab> tabPane,
343
          final Tab oldTab, final Tab newTab ) -> {
344
          updateVariableNameInjector();
345
346
          // If there was no old tab, then this is a first time load, which
347
          // can be ignored.
348
          if( oldTab != null ) {
349
            if( newTab == null ) {
350
              closeRemainingTab();
351
            }
352
            else {
353
              // Update the preview with the edited text.
354
              refreshSelectedTab( (FileEditorTab) newTab );
355
            }
356
          }
357
        }
358
    );
359
  }
360
361
  /**
362
   * Reloads the preferences from the previous session.
363
   */
364
  private void restorePreferences() {
365
    restoreDefinitionPane();
366
    getFileEditorPane().restorePreferences();
367
  }
368
369
  /**
370
   * Ensure that the keyboard events are received when a new tab is added
371
   * to the user interface.
372
   *
373
   * @param tab The tab that can trigger keyboard events, such as control+space.
374
   */
375
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
376
    final VariableNameInjector vin = getVariableNameInjector();
377
    vin.initKeyboardEventListeners( tab );
378
379
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler );
380
  }
381
382
  private void initTextChangeListener( final FileEditorTab tab ) {
383
    tab.addTextChangeListener(
384
        ( ObservableValue<? extends String> editor,
385
          final String oldValue, final String newValue ) ->
386
            refreshSelectedTab( tab )
387
    );
388
  }
389
390
  private void updateVariableNameInjector() {
391
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
392
  }
393
394
  private void setVariableNameInjector( final VariableNameInjector injector ) {
395
    this.variableNameInjector = injector;
396
  }
397
398
  private synchronized VariableNameInjector getVariableNameInjector() {
399
    if( this.variableNameInjector == null ) {
400
      final VariableNameInjector vin = createVariableNameInjector();
401
      setVariableNameInjector( vin );
402
    }
403
404
    return this.variableNameInjector;
405
  }
406
407
  private VariableNameInjector createVariableNameInjector() {
408
    final FileEditorTab tab = getActiveFileEditor();
409
    final DefinitionPane pane = getDefinitionPane();
410
411
    return new VariableNameInjector( tab, pane );
412
  }
413
414
  /**
415
   * Called whenever the preview pane becomes out of sync with the file editor
416
   * tab. This can be called when the text changes, the caret paragraph changes,
417
   * or the file tab changes.
418
   *
419
   * @param tab The file editor tab that has been changed in some fashion.
420
   */
421
  private void refreshSelectedTab( final FileEditorTab tab ) {
422
    if( tab == null ) {
423
      return;
424
    }
425
426
    getPreviewPane().setPath( tab.getPath() );
427
428
    // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
429
    final Position p = tab.getCaretOffset();
430
    getLineNumberText().setText(
431
        get( STATUS_BAR_LINE,
432
             p.getMajor() + 1,
433
             p.getMinor() + 1,
434
             tab.getCaretPosition() + 1
435
        )
436
    );
437
438
    Processor<String> processor = getProcessors().get( tab );
439
440
    if( processor == null ) {
441
      processor = createProcessor( tab );
442
      getProcessors().put( tab, processor );
443
    }
444
445
    try {
446
      processor.processChain( tab.getEditorText() );
447
    } catch( final Exception ex ) {
448
      error( ex );
449
    }
450
  }
451
452
  private void refreshActiveTab() {
453
    refreshSelectedTab( getActiveFileEditor() );
454
  }
455
456
  /**
457
   * Called when a definition source is opened.
458
   *
459
   * @param path Path to the definition source that was opened.
460
   */
461
  private void openDefinitions( final Path path ) {
462
    try {
463
      final DefinitionSource ds = createDefinitionSource( path );
464
      setDefinitionSource( ds );
465
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
466
      getUserPreferences().save();
467
468
      final Tooltip tooltipPath = new Tooltip( path.toString() );
469
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
470
471
      final DefinitionPane pane = getDefinitionPane();
472
      pane.update( ds );
473
      pane.addTreeChangeHandler( mTreeHandler );
474
      pane.addKeyEventHandler( mDefinitionKeyHandler );
475
      pane.filenameProperty().setValue( path.getFileName().toString() );
476
      pane.setTooltip( tooltipPath );
477
478
      interpolateResolvedMap();
479
    } catch( final Exception e ) {
480
      error( e );
481
    }
482
  }
483
484
  private void exportDefinitions( final Path path ) {
485
    try {
486
      final DefinitionPane pane = getDefinitionPane();
487
      final TreeItem<String> root = pane.getTreeView().getRoot();
488
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
489
490
      if( problemChild == null ) {
491
        getDefinitionSource().getTreeAdapter().export( root, path );
492
        getNotifier().clear();
493
      }
494
      else {
495
        final String msg = get( "yaml.error.tree.form",
496
                                problemChild.getValue() );
497
        getNotifier().notify( msg );
498
      }
499
    } catch( final Exception e ) {
500
      error( e );
501
    }
502
  }
503
504
  private void interpolateResolvedMap() {
505
    final Map<String, String> treeMap = getDefinitionPane().toMap();
506
    final Map<String, String> map = new HashMap<>( treeMap );
507
    MapInterpolator.interpolate( map );
508
509
    getResolvedMap().clear();
510
    getResolvedMap().putAll( map );
511
  }
512
513
  private void restoreDefinitionPane() {
514
    openDefinitions( getDefinitionPath() );
515
  }
516
517
  /**
518
   * Called when the last open tab is closed to clear the preview pane.
519
   */
520
  private void closeRemainingTab() {
521
    getPreviewPane().clear();
522
  }
523
524
  /**
525
   * Called when an exception occurs that warrants the user's attention.
526
   *
527
   * @param e The exception with a message that the user should know about.
528
   */
529
  private void error( final Exception e ) {
530
    getNotifier().notify( e );
531
  }
532
533
  //---- File actions -------------------------------------------------------
534
535
  /**
536
   * Called when an observable instance has changed. This is called by both the
537
   * snitch service and the notify service. The snitch service can be called for
538
   * different file types, including definition sources.
539
   *
540
   * @param observable The observed instance.
541
   * @param value      The noteworthy item.
542
   */
543
  @Override
544
  public void update( final Observable observable, final Object value ) {
545
    if( value != null ) {
546
      if( observable instanceof Snitch && value instanceof Path ) {
547
        updateSelectedTab();
548
      }
549
      else if( observable instanceof Notifier && value instanceof String ) {
550
        updateStatusBar( (String) value );
551
      }
552
    }
553
  }
554
555
  /**
556
   * Updates the status bar to show the given message.
557
   *
558
   * @param s The message to show in the status bar.
559
   */
560
  private void updateStatusBar( final String s ) {
561
    Platform.runLater(
562
        () -> {
563
          final int index = s.indexOf( '\n' );
564
          final String message = s.substring(
565
              0, index > 0 ? index : s.length() );
566
567
          getStatusBar().setText( message );
568
        }
569
    );
570
  }
571
572
  /**
573
   * Called when a file has been modified.
574
   */
575
  private void updateSelectedTab() {
576
    Platform.runLater(
577
        () -> {
578
          // Brute-force XSLT file reload by re-instantiating all processors.
579
          resetProcessors();
580
          refreshActiveTab();
581
        }
582
    );
583
  }
584
585
  /**
586
   * After resetting the processors, they will refresh anew to be up-to-date
587
   * with the files (text and definition) currently loaded into the editor.
588
   */
589
  private void resetProcessors() {
590
    getProcessors().clear();
591
  }
592
593
  //---- File actions -------------------------------------------------------
594
595
  private void fileNew() {
596
    getFileEditorPane().newEditor();
597
  }
598
599
  private void fileOpen() {
600
    getFileEditorPane().openFileDialog();
601
  }
602
603
  private void fileClose() {
604
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
605
  }
606
607
  /**
608
   * TODO: Upon closing, first remove the tab change listeners. (There's no
609
   * need to re-render each tab when all are being closed.)
610
   */
611
  private void fileCloseAll() {
612
    getFileEditorPane().closeAllEditors();
613
  }
614
615
  private void fileSave() {
616
    getFileEditorPane().saveEditor( getActiveFileEditor() );
617
  }
618
619
  private void fileSaveAs() {
620
    final FileEditorTab editor = getActiveFileEditor();
621
    getFileEditorPane().saveEditorAs( editor );
622
    getProcessors().remove( editor );
623
624
    try {
625
      refreshSelectedTab( editor );
626
    } catch( final Exception ex ) {
627
      getNotifier().notify( ex );
628
    }
629
  }
630
631
  private void fileSaveAll() {
632
    getFileEditorPane().saveAllEditors();
633
  }
634
635
  private void fileExit() {
636
    final Window window = getWindow();
637
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
638
  }
639
640
  //---- Edit actions -------------------------------------------------------
641
642
  /**
643
   * Used to find text in the active file editor window.
644
   */
645
  private void editFind() {
646
    final TextField input = getFindTextField();
647
    getStatusBar().setGraphic( input );
648
    input.requestFocus();
649
  }
650
651
  public void editFindNext() {
652
    getActiveFileEditor().searchNext( getFindTextField().getText() );
653
  }
654
655
  public void editPreferences() {
656
    getUserPreferences().show();
657
  }
658
659
  //---- Insert actions -----------------------------------------------------
660
661
  /**
662
   * Delegates to the active editor to handle wrapping the current text
663
   * selection with leading and trailing strings.
664
   *
665
   * @param leading  The string to put before the selection.
666
   * @param trailing The string to put after the selection.
667
   */
668
  private void insertMarkdown(
669
      final String leading, final String trailing ) {
670
    getActiveEditor().surroundSelection( leading, trailing );
671
  }
672
673
  @SuppressWarnings("SameParameterValue")
674
  private void insertMarkdown(
675
      final String leading, final String trailing, final String hint ) {
676
    getActiveEditor().surroundSelection( leading, trailing, hint );
677
  }
678
679
  //---- Help actions -------------------------------------------------------
680
681
  private void helpAbout() {
682
    final Alert alert = new Alert( AlertType.INFORMATION );
683
    alert.setTitle( get( "Dialog.about.title" ) );
684
    alert.setHeaderText( get( "Dialog.about.header" ) );
685
    alert.setContentText( get( "Dialog.about.content" ) );
686
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
687
    alert.initOwner( getWindow() );
688
689
    alert.showAndWait();
690
  }
691
692
  //---- Member creators ----------------------------------------------------
693
694
  /**
695
   * Factory to create processors that are suited to different file types.
696
   *
697
   * @param tab The tab that is subjected to processing.
698
   * @return A processor suited to the file type specified by the tab's path.
699
   */
700
  private Processor<String> createProcessor( final FileEditorTab tab ) {
701
    return createProcessorFactory().createProcessor( tab );
702
  }
703
704
  private ProcessorFactory createProcessorFactory() {
705
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
706
  }
707
708
  private HTMLPreviewPane createHTMLPreviewPane() {
709
    return new HTMLPreviewPane();
710
  }
711
712
  private DefinitionSource createDefaultDefinitionSource() {
713
    return new YamlDefinitionSource( getDefinitionPath() );
714
  }
715
716
  private DefinitionSource createDefinitionSource( final Path path ) {
717
    try {
718
      return createDefinitionFactory().createDefinitionSource( path );
719
    } catch( final Exception ex ) {
720
      error( ex );
721
      return createDefaultDefinitionSource();
722
    }
723
  }
724
725
  private TextField createFindTextField() {
726
    return new TextField();
727
  }
728
729
  /**
730
   * Create an editor pane to hold file editor tabs.
731
   *
732
   * @return A new instance, never null.
733
   */
734
  private FileEditorTabPane createFileEditorPane() {
735
    return new FileEditorTabPane( mScrollEventObserver );
736
  }
737
738
  private DefinitionFactory createDefinitionFactory() {
739
    return new DefinitionFactory();
740
  }
741
742
  private StatusBar createStatusBar() {
743
    return new StatusBar();
744
  }
745
746
  private Scene createScene() {
747
    final SplitPane splitPane = new SplitPane(
748
        getDefinitionPane().getNode(),
749
        getFileEditorPane().getNode(),
750
        getPreviewPane().getNode() );
751
752
    splitPane.setDividerPositions(
753
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
754
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
755
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
756
757
    getDefinitionPane().prefHeightProperty().bind( splitPane.heightProperty() );
758
759
    final BorderPane borderPane = new BorderPane();
760
    borderPane.setPrefSize( 1024, 800 );
761
    borderPane.setTop( createMenuBar() );
762
    borderPane.setBottom( getStatusBar() );
763
    borderPane.setCenter( splitPane );
764
765
    final VBox statusBar = new VBox();
766
    statusBar.setAlignment( Pos.BASELINE_CENTER );
767
    statusBar.getChildren().add( getLineNumberText() );
768
    getStatusBar().getRightItems().add( statusBar );
769
770
    return new Scene( borderPane );
771
  }
772
773
  private Text createLineNumberText() {
774
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
775
  }
776
777
  private Node createMenuBar() {
778
    final BooleanBinding activeFileEditorIsNull =
779
        getFileEditorPane().activeFileEditorProperty().isNull();
780
781
    // File actions
782
    final Action fileNewAction = new ActionBuilder()
783
        .setText( "Main.menu.file.new" )
784
        .setAccelerator( "Shortcut+N" )
785
        .setIcon( FILE_ALT )
786
        .setAction( e -> fileNew() )
787
        .build();
788
    final Action fileOpenAction = new ActionBuilder()
789
        .setText( "Main.menu.file.open" )
790
        .setAccelerator( "Shortcut+O" )
791
        .setIcon( FOLDER_OPEN_ALT )
792
        .setAction( e -> fileOpen() )
793
        .build();
794
    final Action fileCloseAction = new ActionBuilder()
795
        .setText( "Main.menu.file.close" )
796
        .setAccelerator( "Shortcut+W" )
797
        .setAction( e -> fileClose() )
798
        .setDisable( activeFileEditorIsNull )
799
        .build();
800
    final Action fileCloseAllAction = new ActionBuilder()
801
        .setText( "Main.menu.file.close_all" )
802
        .setAction( e -> fileCloseAll() )
803
        .setDisable( activeFileEditorIsNull )
804
        .build();
805
    final Action fileSaveAction = new ActionBuilder()
806
        .setText( "Main.menu.file.save" )
807
        .setAccelerator( "Shortcut+S" )
808
        .setIcon( FLOPPY_ALT )
809
        .setAction( e -> fileSave() )
810
        .setDisable( createActiveBooleanProperty(
811
            FileEditorTab::modifiedProperty ).not() )
812
        .build();
813
    final Action fileSaveAsAction = new ActionBuilder()
814
        .setText( "Main.menu.file.save_as" )
815
        .setAction( e -> fileSaveAs() )
816
        .setDisable( activeFileEditorIsNull )
817
        .build();
818
    final Action fileSaveAllAction = new ActionBuilder()
819
        .setText( "Main.menu.file.save_all" )
820
        .setAccelerator( "Shortcut+Shift+S" )
821
        .setAction( e -> fileSaveAll() )
822
        .setDisable( Bindings.not(
823
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
824
        .build();
825
    final Action fileExitAction = new ActionBuilder()
826
        .setText( "Main.menu.file.exit" )
827
        .setAction( e -> fileExit() )
828
        .build();
829
830
    // Edit actions
831
    final Action editUndoAction = new ActionBuilder()
832
        .setText( "Main.menu.edit.undo" )
833
        .setAccelerator( "Shortcut+Z" )
834
        .setIcon( UNDO )
835
        .setAction( e -> getActiveEditor().undo() )
836
        .setDisable( createActiveBooleanProperty(
837
            FileEditorTab::canUndoProperty ).not() )
838
        .build();
839
    final Action editRedoAction = new ActionBuilder()
840
        .setText( "Main.menu.edit.redo" )
841
        .setAccelerator( "Shortcut+Y" )
842
        .setIcon( REPEAT )
843
        .setAction( e -> getActiveEditor().redo() )
844
        .setDisable( createActiveBooleanProperty(
845
            FileEditorTab::canRedoProperty ).not() )
846
        .build();
847
    final Action editFindAction = new ActionBuilder()
848
        .setText( "Main.menu.edit.find" )
849
        .setAccelerator( "Ctrl+F" )
850
        .setIcon( SEARCH )
851
        .setAction( e -> editFind() )
852
        .setDisable( activeFileEditorIsNull )
853
        .build();
854
    final Action editFindNextAction = new ActionBuilder()
855
        .setText( "Main.menu.edit.find.next" )
856
        .setAccelerator( "F3" )
857
        .setIcon( null )
858
        .setAction( e -> editFindNext() )
859
        .setDisable( activeFileEditorIsNull )
860
        .build();
861
    final Action editPreferencesAction = new ActionBuilder()
862
        .setText( "Main.menu.edit.preferences" )
863
        .setAccelerator( "Ctrl+Alt+S" )
864
        .setAction( e -> editPreferences() )
865
        .build();
866
867
    // Insert actions
868
    final Action insertBoldAction = new ActionBuilder()
869
        .setText( "Main.menu.insert.bold" )
870
        .setAccelerator( "Shortcut+B" )
871
        .setIcon( BOLD )
872
        .setAction( e -> insertMarkdown( "**", "**" ) )
873
        .setDisable( activeFileEditorIsNull )
874
        .build();
875
    final Action insertItalicAction = new ActionBuilder()
876
        .setText( "Main.menu.insert.italic" )
877
        .setAccelerator( "Shortcut+I" )
878
        .setIcon( ITALIC )
879
        .setAction( e -> insertMarkdown( "*", "*" ) )
880
        .setDisable( activeFileEditorIsNull )
881
        .build();
882
    final Action insertSuperscriptAction = new ActionBuilder()
883
        .setText( "Main.menu.insert.superscript" )
884
        .setAccelerator( "Shortcut+[" )
885
        .setIcon( SUPERSCRIPT )
886
        .setAction( e -> insertMarkdown( "^", "^" ) )
887
        .setDisable( activeFileEditorIsNull )
888
        .build();
889
    final Action insertSubscriptAction = new ActionBuilder()
890
        .setText( "Main.menu.insert.subscript" )
891
        .setAccelerator( "Shortcut+]" )
892
        .setIcon( SUBSCRIPT )
893
        .setAction( e -> insertMarkdown( "~", "~" ) )
894
        .setDisable( activeFileEditorIsNull )
895
        .build();
896
    final Action insertStrikethroughAction = new ActionBuilder()
897
        .setText( "Main.menu.insert.strikethrough" )
898
        .setAccelerator( "Shortcut+T" )
899
        .setIcon( STRIKETHROUGH )
900
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
901
        .setDisable( activeFileEditorIsNull )
902
        .build();
903
    final Action insertBlockquoteAction = new ActionBuilder()
904
        .setText( "Main.menu.insert.blockquote" )
905
        .setAccelerator( "Ctrl+Q" )
906
        .setIcon( QUOTE_LEFT )
907
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
908
        .setDisable( activeFileEditorIsNull )
909
        .build();
910
    final Action insertCodeAction = new ActionBuilder()
911
        .setText( "Main.menu.insert.code" )
912
        .setAccelerator( "Shortcut+K" )
913
        .setIcon( CODE )
914
        .setAction( e -> insertMarkdown( "`", "`" ) )
915
        .setDisable( activeFileEditorIsNull )
916
        .build();
917
    final Action insertFencedCodeBlockAction = new ActionBuilder()
918
        .setText( "Main.menu.insert.fenced_code_block" )
919
        .setAccelerator( "Shortcut+Shift+K" )
920
        .setIcon( FILE_CODE_ALT )
921
        .setAction( e -> getActiveEditor().surroundSelection(
922
            "\n\n```\n",
923
            "\n```\n\n",
924
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
925
        .setDisable( activeFileEditorIsNull )
926
        .build();
927
    final Action insertLinkAction = new ActionBuilder()
928
        .setText( "Main.menu.insert.link" )
929
        .setAccelerator( "Shortcut+L" )
930
        .setIcon( LINK )
931
        .setAction( e -> getActiveEditor().insertLink() )
932
        .setDisable( activeFileEditorIsNull )
933
        .build();
934
    final Action insertImageAction = new ActionBuilder()
935
        .setText( "Main.menu.insert.image" )
936
        .setAccelerator( "Shortcut+G" )
937
        .setIcon( PICTURE_ALT )
938
        .setAction( e -> getActiveEditor().insertImage() )
939
        .setDisable( activeFileEditorIsNull )
940
        .build();
941
942
    // Number of header actions (H1 ... H3)
943
    final int HEADERS = 3;
944
    final Action[] headers = new Action[ HEADERS ];
945
946
    for( int i = 1; i <= HEADERS; i++ ) {
947
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
948
      final String markup = String.format( "%n%n%s ", hashes );
949
      final String text = "Main.menu.insert.header." + i;
950
      final String accelerator = "Shortcut+" + i;
951
      final String prompt = text + ".prompt";
952
953
      headers[ i - 1 ] = new ActionBuilder()
954
          .setText( text )
955
          .setAccelerator( accelerator )
956
          .setIcon( HEADER )
957
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
958
          .setDisable( activeFileEditorIsNull )
959
          .build();
960
    }
961
962
    final Action insertUnorderedListAction = new ActionBuilder()
963
        .setText( "Main.menu.insert.unordered_list" )
964
        .setAccelerator( "Shortcut+U" )
965
        .setIcon( LIST_UL )
966
        .setAction( e -> getActiveEditor()
967
            .surroundSelection( "\n\n* ", "" ) )
968
        .setDisable( activeFileEditorIsNull )
969
        .build();
970
    final Action insertOrderedListAction = new ActionBuilder()
971
        .setText( "Main.menu.insert.ordered_list" )
972
        .setAccelerator( "Shortcut+Shift+O" )
973
        .setIcon( LIST_OL )
974
        .setAction( e -> insertMarkdown(
975
            "\n\n1. ", "" ) )
976
        .setDisable( activeFileEditorIsNull )
977
        .build();
978
    final Action insertHorizontalRuleAction = new ActionBuilder()
979
        .setText( "Main.menu.insert.horizontal_rule" )
980
        .setAccelerator( "Shortcut+H" )
981
        .setAction( e -> insertMarkdown(
982
            "\n\n---\n\n", "" ) )
983
        .setDisable( activeFileEditorIsNull )
984
        .build();
985
986
    // Help actions
987
    final Action helpAboutAction = new ActionBuilder()
988
        .setText( "Main.menu.help.about" )
989
        .setAction( e -> helpAbout() )
990
        .build();
991
992
    //---- MenuBar ----
993
    final Menu fileMenu = ActionUtils.createMenu(
994
        get( "Main.menu.file" ),
995
        fileNewAction,
996
        fileOpenAction,
997
        null,
998
        fileCloseAction,
999
        fileCloseAllAction,
1000
        null,
1001
        fileSaveAction,
1002
        fileSaveAsAction,
1003
        fileSaveAllAction,
1004
        null,
1005
        fileExitAction );
1006
1007
    final Menu editMenu = ActionUtils.createMenu(
1008
        get( "Main.menu.edit" ),
1009
        editUndoAction,
1010
        editRedoAction,
1011
        editFindAction,
1012
        editFindNextAction,
1013
        null,
1014
        editPreferencesAction );
1015
1016
    final Menu insertMenu = ActionUtils.createMenu(
1017
        get( "Main.menu.insert" ),
1018
        insertBoldAction,
1019
        insertItalicAction,
1020
        insertSuperscriptAction,
1021
        insertSubscriptAction,
1022
        insertStrikethroughAction,
1023
        insertBlockquoteAction,
1024
        insertCodeAction,
1025
        insertFencedCodeBlockAction,
1026
        null,
1027
        insertLinkAction,
1028
        insertImageAction,
1029
        null,
1030
        headers[ 0 ],
1031
        headers[ 1 ],
1032
        headers[ 2 ],
1033
        null,
1034
        insertUnorderedListAction,
1035
        insertOrderedListAction,
1036
        insertHorizontalRuleAction );
1037
1038
    final Menu helpMenu = ActionUtils.createMenu(
1039
        get( "Main.menu.help" ),
1040
        helpAboutAction );
1041
1042
    final MenuBar menuBar = new MenuBar(
1043
        fileMenu,
1044
        editMenu,
1045
        insertMenu,
1046
        helpMenu );
1047
1048
    //---- ToolBar ----
1049
    final ToolBar toolBar = ActionUtils.createToolBar(
1050
        fileNewAction,
1051
        fileOpenAction,
1052
        fileSaveAction,
1053
        null,
1054
        editUndoAction,
1055
        editRedoAction,
1056
        null,
1057
        insertBoldAction,
1058
        insertItalicAction,
1059
        insertSuperscriptAction,
1060
        insertSubscriptAction,
1061
        insertBlockquoteAction,
1062
        insertCodeAction,
1063
        insertFencedCodeBlockAction,
1064
        null,
1065
        insertLinkAction,
1066
        insertImageAction,
1067
        null,
1068
        headers[ 0 ],
1069
        null,
1070
        insertUnorderedListAction,
1071
        insertOrderedListAction );
1072
1073
    return new VBox( menuBar, toolBar );
1074
  }
1075
1076
  private UserPreferences createUserPreferences() {
1077
    return new UserPreferences();
1078
  }
1079
1080
  /**
1081
   * Creates a boolean property that is bound to another boolean value of the
1082
   * active editor.
1083
   */
1084
  private BooleanProperty createActiveBooleanProperty(
1085
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1086
1087
    final BooleanProperty b = new SimpleBooleanProperty();
1088
    final FileEditorTab tab = getActiveFileEditor();
1089
1090
    if( tab != null ) {
1091
      b.bind( func.apply( tab ) );
1092
    }
1093
1094
    getFileEditorPane().activeFileEditorProperty().addListener(
1095
        ( observable, oldFileEditor, newFileEditor ) -> {
1096
          b.unbind();
1097
1098
          if( newFileEditor == null ) {
1099
            b.set( false );
1100
          }
1101
          else {
1102
            b.bind( func.apply( newFileEditor ) );
1103
          }
1104
        }
1105
    );
1106
1107
    return b;
1108
  }
1109
1110
  //---- Convenience accessors ----------------------------------------------
1111
1112
  private Preferences getPreferences() {
1113
    return OPTIONS.getState();
1114
  }
1115
1116
  private float getFloat( final String key, final float defaultValue ) {
1117
    return getPreferences().getFloat( key, defaultValue );
1118
  }
1119
1120
  public Window getWindow() {
1121
    return getScene().getWindow();
1122
  }
1123
1124
  private MarkdownEditorPane getActiveEditor() {
1125
    final EditorPane pane = getActiveFileEditor().getEditorPane();
1126
1127
    return pane instanceof MarkdownEditorPane
1128
        ? (MarkdownEditorPane) pane
1129
        : new MarkdownEditorPane();
1130
  }
1131
1132
  private FileEditorTab getActiveFileEditor() {
1133
    return getFileEditorPane().getActiveFileEditor();
1134
  }
1135
1136
  //---- Member accessors ---------------------------------------------------
1137
1138
  protected Scene getScene() {
1139
    return mScene;
1140
  }
1141
1142
  private Map<FileEditorTab, Processor<String>> getProcessors() {
1143
    return mProcessors;
1144
  }
1145
1146
  private FileEditorTabPane getFileEditorPane() {
1147
    if( this.fileEditorPane == null ) {
1148
      this.fileEditorPane = createFileEditorPane();
1149
    }
1150
1151
    return this.fileEditorPane;
1152
  }
1153
1154
  private HTMLPreviewPane getPreviewPane() {
1155
    return mPreviewPane;
1156
  }
1157
1158
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
1159
    assert definitionSource != null;
1160
    mDefinitionSource = definitionSource;
1161
  }
1162
1163
  private DefinitionSource getDefinitionSource() {
1164
    return mDefinitionSource;
1165
  }
1166
1167
  private DefinitionPane getDefinitionPane() {
1168
    return mDefinitionPane;
1169
  }
1170
1171
  private Notifier getNotifier() {
1172
    return NOTIFIER;
1173
  }
1174
1175
  private Text getLineNumberText() {
1176
    return mLineNumberText;
1177
  }
1178
1179
  private StatusBar getStatusBar() {
1180
    return mStatusBar;
1181
  }
1182
1183
  private TextField getFindTextField() {
1184
    return mFindTextField;
1185
  }
1186
1187
  /**
1188
   * Returns the variable map of interpolated definitions.
1189
   *
1190
   * @return A map to help dereference variables.
1191
   */
1192
  private Map<String, String> getResolvedMap() {
1193
    return mResolvedMap;
1194
  }
1195
1196
  //---- Persistence accessors ----------------------------------------------
1197
  private UserPreferences getUserPreferences() {
1198
    return OPTIONS.getUserPreferences();
1199
  }
1200
1201
  private Path getDefinitionPath() {
1202
    return getUserPreferences().getDefinitionPath();
1203
  }
1204
1205
  private File getImagesDirectory() {
1206
    return getUserPreferences().getImagesDirectory();
1207
  }
1208
1209
  private String getImagesOrder() {
1210
    return getUserPreferences().getImagesOrder();
11421211
  }
11431212
}
M src/main/java/com/scrivenvar/Messages.java
122122
  }
123123
124
  public static String get( final String key, final boolean interpolate ) {
125
    return interpolate ? get( key ) : getLiteral( key );
126
  }
127
124128
  /**
125129
   * Returns the value for a key from the message bundle with the arguments
M src/main/java/com/scrivenvar/definition/DefinitionFactory.java
3131
import com.scrivenvar.FileType;
3232
import com.scrivenvar.definition.yaml.YamlDefinitionSource;
33
import com.scrivenvar.util.ProtocolResolver;
3334
34
import java.io.File;
35
import java.net.URI;
36
import java.net.URL;
3735
import java.nio.file.Path;
3836
39
import static com.scrivenvar.Constants.*;
37
import static com.scrivenvar.Constants.DEFINITION_PROTOCOL_FILE;
38
import static com.scrivenvar.Constants.GLOB_PREFIX_DEFINITION;
4039
import static com.scrivenvar.FileType.YAML;
4140
...
6564
    assert path != null;
6665
67
    final String protocol = getProtocol( path.toString() );
66
    final String protocol = ProtocolResolver.getProtocol( path.toString() );
6867
    DefinitionSource result = null;
6968
...
9695
9796
    throw new IllegalArgumentException( filetype.toString() );
98
  }
99
100
  /**
101
   * Returns the protocol for a given URI or filename.
102
   *
103
   * @param source Determine the protocol for this URI or filename.
104
   * @return The protocol for the given source.
105
   */
106
  private String getProtocol( final String source ) {
107
    String protocol;
108
109
    try {
110
      final URI uri = new URI( source );
111
112
      if( uri.isAbsolute() ) {
113
        protocol = uri.getScheme();
114
      }
115
      else {
116
        final URL url = new URL( source );
117
        protocol = url.getProtocol();
118
      }
119
    } catch( final Exception e ) {
120
      // Could be HTTP, HTTPS?
121
      if( source.startsWith( "//" ) ) {
122
        throw new IllegalArgumentException( "Relative context: " + source );
123
      }
124
      else {
125
        final File file = new File( source );
126
        protocol = getProtocol( file );
127
      }
128
    }
129
130
    return protocol;
131
  }
132
133
  /**
134
   * Returns the protocol for a given file.
135
   *
136
   * @param file Determine the protocol for this file.
137
   * @return The protocol for the given file.
138
   */
139
  private String getProtocol( final File file ) {
140
    String result;
141
142
    try {
143
      result = file.toURI().toURL().getProtocol();
144
    } catch( final Exception e ) {
145
      result = DEFINITION_PROTOCOL_UNKNOWN;
146
    }
147
148
    return result;
14997
  }
15098
}
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
4545
4646
/**
47
 * Provides the user interface that holdsa {@link TreeView}, which
48
 * allows users to interact with key/value pairs loaded from the
49
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
50
 *
51
 * @author White Magic Software, Ltd.
52
 */
53
public final class DefinitionPane extends TitledPane {
54
55
  /**
56
   * Trimmed off the end of a word to match a variable name.
57
   */
58
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
59
60
  /**
61
   * Contains a view of the definitions.
62
   */
63
  private final TreeView<String> mTreeView = new TreeView<>();
64
65
  /**
66
   * Handlers for key press events.
67
   */
68
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
69
      = new HashSet<>();
70
71
  /**
72
   * Definition file name shown in the title of the pane.
73
   */
74
  private final StringProperty mFilename = new SimpleStringProperty();
75
76
  /**
77
   * Constructs a definition pane with a given tree view root.
78
   */
79
  public DefinitionPane() {
80
    final var treeView = getTreeView();
81
    treeView.setEditable( true );
82
    treeView.setCellFactory( cell -> createTreeCell() );
83
    treeView.setContextMenu( createContextMenu() );
84
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
85
    treeView.setShowRoot( false );
86
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
87
88
    textProperty().bind( mFilename );
89
90
    setContent( treeView );
91
    setCollapsible( false );
92
  }
93
94
  /**
95
   * Changes the root of the {@link TreeView} to the root of the
96
   * {@link TreeView} from the {@link DefinitionSource}.
97
   *
98
   * @param definitionSource Container for the hierarchy of key/value pairs
99
   *                         to replace the existing hierarchy.
100
   */
101
  public void update( final DefinitionSource definitionSource ) {
102
    assert definitionSource != null;
103
104
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
105
    final TreeItem<String> root = treeAdapter.adapt(
106
        get( "Pane.definition.node.root.title" )
107
    );
108
109
    getTreeView().setRoot( root );
110
  }
111
112
  public Map<String, String> toMap() {
113
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
114
  }
115
116
  /**
117
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
118
   * is modified. The modifications include: item value changes, item additions,
119
   * and item removals.
120
   * <p>
121
   * Safe to call multiple times; if a handler is already registered, the
122
   * old handler is used.
123
   * </p>
124
   *
125
   * @param handler The handler to call whenever any {@link TreeItem} changes.
126
   */
127
  public void addTreeChangeHandler(
128
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
129
    final TreeItem<String> root = getTreeView().getRoot();
130
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
131
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
132
  }
133
134
  public void addKeyEventHandler(
135
      final EventHandler<? super KeyEvent> handler ) {
136
    getKeyEventHandlers().add( handler );
137
  }
138
139
  /**
140
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
141
   * well-formed for export. A tree is considered well-formed if the following
142
   * conditions are met:
143
   *
144
   * <ul>
145
   *   <li>The root node contains at least one child node having a leaf.</li>
146
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
147
   * </ul>
148
   *
149
   * @return {@code null} if the document is well-formed, otherwise the
150
   * problematic child {@link TreeItem}.
151
   */
152
  public TreeItem<String> isTreeWellFormed() {
153
    final var root = getTreeView().getRoot();
154
155
    for( final var child : root.getChildren() ) {
156
      final var problemChild = isWellFormed( child );
157
158
      if( child.isLeaf() || problemChild != null ) {
159
        return problemChild;
160
      }
161
    }
162
163
    return null;
164
  }
165
166
  /**
167
   * Determines whether the document is well-formed by ensuring that
168
   * child branches do not contain multiple leaves.
169
   *
170
   * @param item The sub-tree to check for well-formedness.
171
   * @return {@code null} when the tree is well-formed, otherwise the
172
   * problematic {@link TreeItem}.
173
   */
174
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
175
    int childLeafs = 0;
176
    int childBranches = 0;
177
178
    for( final TreeItem<String> child : item.getChildren() ) {
179
      if( child.isLeaf() ) {
180
        childLeafs++;
181
      }
182
      else {
183
        childBranches++;
184
      }
185
186
      final var problemChild = isWellFormed( child );
187
188
      if( problemChild != null ) {
189
        return problemChild;
190
      }
191
    }
192
193
    return ((childBranches > 0 && childLeafs == 0) ||
194
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
195
  }
196
197
  /**
198
   * Returns the leaf that matches the given value. If the value is terminally
199
   * punctuated, the punctuation is removed if no match was found.
200
   *
201
   * @param value    The value to find, never null.
202
   * @param findMode Defines how to match words.
203
   * @return The leaf that contains the given value, or null if neither the
204
   * original value nor the terminally-trimmed value was found.
205
   */
206
  public VariableTreeItem<String> findLeaf(
207
      final String value, final FindMode findMode ) {
208
    final VariableTreeItem<String> root = getTreeRoot();
209
    final VariableTreeItem<String> leaf = root.findLeaf( value, findMode );
210
211
    return leaf == null
212
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
213
        : leaf;
214
  }
215
216
  /**
217
   * Removes punctuation from the end of a string.
218
   *
219
   * @param s The string to trim, never null.
220
   * @return The string trimmed of all terminal characters from the end
221
   */
222
  private String rtrimTerminalPunctuation( final String s ) {
223
    assert s != null;
224
    int index = s.length() - 1;
225
226
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
227
      index--;
228
    }
229
230
    return s.substring( 0, index );
231
  }
232
233
  /**
234
   * Expands the node to the root, recursively.
235
   *
236
   * @param <T>  The type of tree item to expand (usually String).
237
   * @param node The node to expand.
238
   */
239
  public <T> void expand( final TreeItem<T> node ) {
240
    if( node != null ) {
241
      expand( node.getParent() );
242
243
      if( !node.isLeaf() ) {
244
        node.setExpanded( true );
245
      }
246
    }
247
  }
248
249
  public void select( final TreeItem<String> item ) {
250
    getSelectionModel().clearSelection();
251
    getSelectionModel().select( getTreeView().getRow( item ) );
252
  }
253
254
  /**
255
   * Collapses the tree, recursively.
256
   */
257
  public void collapse() {
258
    collapse( getTreeRoot().getChildren() );
259
  }
260
261
  /**
262
   * Collapses the tree, recursively.
263
   *
264
   * @param <T>   The type of tree item to expand (usually String).
265
   * @param nodes The nodes to collapse.
266
   */
267
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
268
    for( final TreeItem<T> node : nodes ) {
269
      node.setExpanded( false );
270
      collapse( node.getChildren() );
271
    }
272
  }
273
274
  /**
275
   * @return {@code true} when the user is editing a {@link TreeItem}.
276
   */
277
  private boolean isEditingTreeItem() {
278
    return getTreeView().editingItemProperty().getValue() != null;
279
  }
280
281
  /**
282
   * Changes to edit mode for the selected item.
283
   */
284
  private void editSelectedItem() {
285
    getTreeView().edit( getSelectedItem() );
286
  }
287
288
  /**
289
   * Removes all selected items from the {@link TreeView}.
290
   */
291
  private void deleteSelectedItems() {
292
    for( final TreeItem<String> item : getSelectedItems() ) {
293
      final TreeItem<String> parent = item.getParent();
294
295
      if( parent != null ) {
296
        parent.getChildren().remove( item );
297
      }
298
    }
299
  }
300
301
  /**
302
   * Deletes the selected item.
303
   */
304
  private void deleteSelectedItem() {
305
    final TreeItem<String> c = getSelectedItem();
306
    getSiblings( c ).remove( c );
307
  }
308
309
  /**
310
   * Adds a new item under the selected item (or root if nothing is selected).
311
   * There are a few conditions to consider: when adding to the root,
312
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
313
   * root must contain two items: a key and a value.
314
   */
315
  private void addItem() {
316
    final TreeItem<String> value = createTreeItem();
317
    getSelectedItem().getChildren().add( value );
318
    expand( value );
319
    select( value );
320
  }
321
322
  private ContextMenu createContextMenu() {
323
    final ContextMenu menu = new ContextMenu();
324
    final ObservableList<MenuItem> items = menu.getItems();
325
326
    addMenuItem( items, "Definition.menu.create" )
327
        .setOnAction( e -> addItem() );
328
329
    addMenuItem( items, "Definition.menu.rename" )
330
        .setOnAction( e -> editSelectedItem() );
331
332
    addMenuItem( items, "Definition.menu.remove" )
333
        .setOnAction( e -> deleteSelectedItem() );
334
335
    return menu;
336
  }
337
338
  /**
339
   * Executes hot-keys for edits to the definition tree.
340
   *
341
   * @param event Contains the key code of the key that was pressed.
342
   */
343
  private void keyEventFilter( final KeyEvent event ) {
344
    if( !isEditingTreeItem() ) {
345
      switch( event.getCode() ) {
346
        case ENTER:
347
          expand( getSelectedItem() );
348
          event.consume();
349
          break;
350
351
        case DELETE:
352
          deleteSelectedItems();
353
          break;
354
355
        case INSERT:
356
          addItem();
357
          break;
358
359
        case R:
360
          if( event.isControlDown() ) {
361
            editSelectedItem();
362
          }
363
364
          break;
365
      }
366
367
      for( final var handler : getKeyEventHandlers() ) {
368
        handler.handle( event );
369
      }
370
    }
371
  }
372
373
  /**
374
   * Adds a menu item to a list of menu items.
375
   *
376
   * @param items    The list of menu items to append to.
377
   * @param labelKey The resource bundle key name for the menu item's label.
378
   * @return The menu item added to the list of menu items.
379
   */
380
  private MenuItem addMenuItem(
381
      final List<MenuItem> items, final String labelKey ) {
382
    final MenuItem menuItem = createMenuItem( labelKey );
383
    items.add( menuItem );
384
    return menuItem;
385
  }
386
387
  private MenuItem createMenuItem( final String labelKey ) {
388
    return new MenuItem( get( labelKey ) );
389
  }
390
391
  private VariableTreeItem<String> createTreeItem() {
392
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
393
  }
394
395
  private TreeCell<String> createTreeCell() {
396
    return new TextFieldTreeCell<>(
397
        createStringConverter() ) {
398
      @Override
399
      public void commitEdit( final String newValue ) {
400
        super.commitEdit( newValue );
401
        select( getTreeItem() );
402
        requestFocus();
403
      }
404
    };
405
  }
406
407
  @Override
408
  public void requestFocus() {
409
    super.requestFocus();
410
    getTreeView().requestFocus();
411
  }
412
413
  private StringConverter<String> createStringConverter() {
414
    return new StringConverter<>() {
415
      @Override
416
      public String toString( final String object ) {
417
        return object == null ? "" : object;
418
      }
419
420
      @Override
421
      public String fromString( final String string ) {
422
        return string == null ? "" : string;
423
      }
424
    };
425
  }
426
427
  /**
428
   * Returns the tree view that contains the definition hierarchy.
429
   *
430
   * @return A non-null instance.
431
   */
432
  public TreeView<String> getTreeView() {
433
    return mTreeView;
434
  }
435
436
  /**
437
   * Returns this pane.
438
   *
439
   * @return this
440
   */
441
  public Node getNode() {
442
    return this;
443
  }
444
445
  /**
446
   * Returns the property used to set the title of the pane: the file name.
447
   *
448
   * @return A non-null property used for showing the definition file name.
449
   */
450
  public StringProperty filenameProperty() {
451
    return mFilename;
452
  }
453
454
  /**
455
   * Returns the root of the tree.
456
   *
457
   * @return The first node added to the definition tree.
458
   */
459
  private VariableTreeItem<String> getTreeRoot() {
460
    final TreeItem<String> root = getTreeView().getRoot();
461
462
    return root instanceof VariableTreeItem ?
463
        (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" );
464
  }
465
466
  private ObservableList<TreeItem<String>> getSiblings(
467
      final TreeItem<String> item ) {
468
    final TreeItem<String> root = getTreeView().getRoot();
469
    final TreeItem<String> parent =
470
        (item == null || item == root) ? root : item.getParent();
471
472
    return parent.getChildren();
473
  }
474
475
  private MultipleSelectionModel<TreeItem<String>> getSelectionModel() {
476
    return getTreeView().getSelectionModel();
477
  }
478
479
  /**
480
   * Returns a copy of all the selected items.
481
   *
482
   * @return A list, possibly empty, containing all selected items in the
483
   * {@link TreeView}.
484
   */
485
  private List<TreeItem<String>> getSelectedItems() {
486
    return new LinkedList<>( getSelectionModel().getSelectedItems() );
47
 * Provides the user interface that holds a {@link TreeView}, which
48
 * allows users to interact with key/value pairs loaded from the
49
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
50
 *
51
 * @author White Magic Software, Ltd.
52
 */
53
public final class DefinitionPane extends TitledPane {
54
55
  /**
56
   * Trimmed off the end of a word to match a variable name.
57
   */
58
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
59
60
  /**
61
   * Contains a view of the definitions.
62
   */
63
  private final TreeView<String> mTreeView = new TreeView<>();
64
65
  /**
66
   * Handlers for key press events.
67
   */
68
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
69
      = new HashSet<>();
70
71
  /**
72
   * Definition file name shown in the title of the pane.
73
   */
74
  private final StringProperty mFilename = new SimpleStringProperty();
75
76
  /**
77
   * Constructs a definition pane with a given tree view root.
78
   */
79
  public DefinitionPane() {
80
    final var treeView = getTreeView();
81
    treeView.setEditable( true );
82
    treeView.setCellFactory( cell -> createTreeCell() );
83
    treeView.setContextMenu( createContextMenu() );
84
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
85
    treeView.setShowRoot( false );
86
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
87
88
    textProperty().bind( mFilename );
89
90
    setContent( treeView );
91
    setCollapsible( false );
92
  }
93
94
  /**
95
   * Changes the root of the {@link TreeView} to the root of the
96
   * {@link TreeView} from the {@link DefinitionSource}.
97
   *
98
   * @param definitionSource Container for the hierarchy of key/value pairs
99
   *                         to replace the existing hierarchy.
100
   */
101
  public void update( final DefinitionSource definitionSource ) {
102
    assert definitionSource != null;
103
104
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
105
    final TreeItem<String> root = treeAdapter.adapt(
106
        get( "Pane.definition.node.root.title" )
107
    );
108
109
    getTreeView().setRoot( root );
110
  }
111
112
  public Map<String, String> toMap() {
113
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
114
  }
115
116
  /**
117
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
118
   * is modified. The modifications include: item value changes, item additions,
119
   * and item removals.
120
   * <p>
121
   * Safe to call multiple times; if a handler is already registered, the
122
   * old handler is used.
123
   * </p>
124
   *
125
   * @param handler The handler to call whenever any {@link TreeItem} changes.
126
   */
127
  public void addTreeChangeHandler(
128
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
129
    final TreeItem<String> root = getTreeView().getRoot();
130
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
131
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
132
  }
133
134
  public void addKeyEventHandler(
135
      final EventHandler<? super KeyEvent> handler ) {
136
    getKeyEventHandlers().add( handler );
137
  }
138
139
  /**
140
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
141
   * well-formed for export. A tree is considered well-formed if the following
142
   * conditions are met:
143
   *
144
   * <ul>
145
   *   <li>The root node contains at least one child node having a leaf.</li>
146
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
147
   * </ul>
148
   *
149
   * @return {@code null} if the document is well-formed, otherwise the
150
   * problematic child {@link TreeItem}.
151
   */
152
  public TreeItem<String> isTreeWellFormed() {
153
    final var root = getTreeView().getRoot();
154
155
    for( final var child : root.getChildren() ) {
156
      final var problemChild = isWellFormed( child );
157
158
      if( child.isLeaf() || problemChild != null ) {
159
        return problemChild;
160
      }
161
    }
162
163
    return null;
164
  }
165
166
  /**
167
   * Determines whether the document is well-formed by ensuring that
168
   * child branches do not contain multiple leaves.
169
   *
170
   * @param item The sub-tree to check for well-formedness.
171
   * @return {@code null} when the tree is well-formed, otherwise the
172
   * problematic {@link TreeItem}.
173
   */
174
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
175
    int childLeafs = 0;
176
    int childBranches = 0;
177
178
    for( final TreeItem<String> child : item.getChildren() ) {
179
      if( child.isLeaf() ) {
180
        childLeafs++;
181
      }
182
      else {
183
        childBranches++;
184
      }
185
186
      final var problemChild = isWellFormed( child );
187
188
      if( problemChild != null ) {
189
        return problemChild;
190
      }
191
    }
192
193
    return ((childBranches > 0 && childLeafs == 0) ||
194
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
195
  }
196
197
  /**
198
   * Returns the leaf that matches the given value. If the value is terminally
199
   * punctuated, the punctuation is removed if no match was found.
200
   *
201
   * @param value    The value to find, never null.
202
   * @param findMode Defines how to match words.
203
   * @return The leaf that contains the given value, or null if neither the
204
   * original value nor the terminally-trimmed value was found.
205
   */
206
  public VariableTreeItem<String> findLeaf(
207
      final String value, final FindMode findMode ) {
208
    final VariableTreeItem<String> root = getTreeRoot();
209
    final VariableTreeItem<String> leaf = root.findLeaf( value, findMode );
210
211
    return leaf == null
212
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
213
        : leaf;
214
  }
215
216
  /**
217
   * Removes punctuation from the end of a string.
218
   *
219
   * @param s The string to trim, never null.
220
   * @return The string trimmed of all terminal characters from the end
221
   */
222
  private String rtrimTerminalPunctuation( final String s ) {
223
    assert s != null;
224
    int index = s.length() - 1;
225
226
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
227
      index--;
228
    }
229
230
    return s.substring( 0, index );
231
  }
232
233
  /**
234
   * Expands the node to the root, recursively.
235
   *
236
   * @param <T>  The type of tree item to expand (usually String).
237
   * @param node The node to expand.
238
   */
239
  public <T> void expand( final TreeItem<T> node ) {
240
    if( node != null ) {
241
      expand( node.getParent() );
242
243
      if( !node.isLeaf() ) {
244
        node.setExpanded( true );
245
      }
246
    }
247
  }
248
249
  public void select( final TreeItem<String> item ) {
250
    getSelectionModel().clearSelection();
251
    getSelectionModel().select( getTreeView().getRow( item ) );
252
  }
253
254
  /**
255
   * Collapses the tree, recursively.
256
   */
257
  public void collapse() {
258
    collapse( getTreeRoot().getChildren() );
259
  }
260
261
  /**
262
   * Collapses the tree, recursively.
263
   *
264
   * @param <T>   The type of tree item to expand (usually String).
265
   * @param nodes The nodes to collapse.
266
   */
267
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
268
    for( final TreeItem<T> node : nodes ) {
269
      node.setExpanded( false );
270
      collapse( node.getChildren() );
271
    }
272
  }
273
274
  /**
275
   * @return {@code true} when the user is editing a {@link TreeItem}.
276
   */
277
  private boolean isEditingTreeItem() {
278
    return getTreeView().editingItemProperty().getValue() != null;
279
  }
280
281
  /**
282
   * Changes to edit mode for the selected item.
283
   */
284
  private void editSelectedItem() {
285
    getTreeView().edit( getSelectedItem() );
286
  }
287
288
  /**
289
   * Removes all selected items from the {@link TreeView}.
290
   */
291
  private void deleteSelectedItems() {
292
    for( final TreeItem<String> item : getSelectedItems() ) {
293
      final TreeItem<String> parent = item.getParent();
294
295
      if( parent != null ) {
296
        parent.getChildren().remove( item );
297
      }
298
    }
299
  }
300
301
  /**
302
   * Deletes the selected item.
303
   */
304
  private void deleteSelectedItem() {
305
    final TreeItem<String> c = getSelectedItem();
306
    getSiblings( c ).remove( c );
307
  }
308
309
  /**
310
   * Adds a new item under the selected item (or root if nothing is selected).
311
   * There are a few conditions to consider: when adding to the root,
312
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
313
   * root must contain two items: a key and a value.
314
   */
315
  private void addItem() {
316
    final TreeItem<String> value = createTreeItem();
317
    getSelectedItem().getChildren().add( value );
318
    expand( value );
319
    select( value );
320
  }
321
322
  private ContextMenu createContextMenu() {
323
    final ContextMenu menu = new ContextMenu();
324
    final ObservableList<MenuItem> items = menu.getItems();
325
326
    addMenuItem( items, "Definition.menu.create" )
327
        .setOnAction( e -> addItem() );
328
329
    addMenuItem( items, "Definition.menu.rename" )
330
        .setOnAction( e -> editSelectedItem() );
331
332
    addMenuItem( items, "Definition.menu.remove" )
333
        .setOnAction( e -> deleteSelectedItem() );
334
335
    return menu;
336
  }
337
338
  /**
339
   * Executes hot-keys for edits to the definition tree.
340
   *
341
   * @param event Contains the key code of the key that was pressed.
342
   */
343
  private void keyEventFilter( final KeyEvent event ) {
344
    if( !isEditingTreeItem() ) {
345
      switch( event.getCode() ) {
346
        case ENTER:
347
          expand( getSelectedItem() );
348
          event.consume();
349
          break;
350
351
        case DELETE:
352
          deleteSelectedItems();
353
          break;
354
355
        case INSERT:
356
          addItem();
357
          break;
358
359
        case R:
360
          if( event.isControlDown() ) {
361
            editSelectedItem();
362
          }
363
364
          break;
365
      }
366
367
      for( final var handler : getKeyEventHandlers() ) {
368
        handler.handle( event );
369
      }
370
    }
371
  }
372
373
  /**
374
   * Adds a menu item to a list of menu items.
375
   *
376
   * @param items    The list of menu items to append to.
377
   * @param labelKey The resource bundle key name for the menu item's label.
378
   * @return The menu item added to the list of menu items.
379
   */
380
  private MenuItem addMenuItem(
381
      final List<MenuItem> items, final String labelKey ) {
382
    final MenuItem menuItem = createMenuItem( labelKey );
383
    items.add( menuItem );
384
    return menuItem;
385
  }
386
387
  private MenuItem createMenuItem( final String labelKey ) {
388
    return new MenuItem( get( labelKey ) );
389
  }
390
391
  private VariableTreeItem<String> createTreeItem() {
392
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
393
  }
394
395
  private TreeCell<String> createTreeCell() {
396
    return new TextFieldTreeCell<>(
397
        createStringConverter() ) {
398
      @Override
399
      public void commitEdit( final String newValue ) {
400
        super.commitEdit( newValue );
401
        select( getTreeItem() );
402
        requestFocus();
403
      }
404
    };
405
  }
406
407
  @Override
408
  public void requestFocus() {
409
    super.requestFocus();
410
    getTreeView().requestFocus();
411
  }
412
413
  private StringConverter<String> createStringConverter() {
414
    return new StringConverter<>() {
415
      @Override
416
      public String toString( final String object ) {
417
        return object == null ? "" : object;
418
      }
419
420
      @Override
421
      public String fromString( final String string ) {
422
        return string == null ? "" : string;
423
      }
424
    };
425
  }
426
427
  /**
428
   * Returns the tree view that contains the definition hierarchy.
429
   *
430
   * @return A non-null instance.
431
   */
432
  public TreeView<String> getTreeView() {
433
    return mTreeView;
434
  }
435
436
  /**
437
   * Returns this pane.
438
   *
439
   * @return this
440
   */
441
  public Node getNode() {
442
    return this;
443
  }
444
445
  /**
446
   * Returns the property used to set the title of the pane: the file name.
447
   *
448
   * @return A non-null property used for showing the definition file name.
449
   */
450
  public StringProperty filenameProperty() {
451
    return mFilename;
452
  }
453
454
  /**
455
   * Returns the root of the tree.
456
   *
457
   * @return The first node added to the definition tree.
458
   */
459
  private VariableTreeItem<String> getTreeRoot() {
460
    final TreeItem<String> root = getTreeView().getRoot();
461
462
    return root instanceof VariableTreeItem ?
463
        (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" );
464
  }
465
466
  private ObservableList<TreeItem<String>> getSiblings(
467
      final TreeItem<String> item ) {
468
    final TreeItem<String> root = getTreeView().getRoot();
469
    final TreeItem<String> parent =
470
        (item == null || item == root) ? root : item.getParent();
471
472
    return parent.getChildren();
473
  }
474
475
  private MultipleSelectionModel<TreeItem<String>> getSelectionModel() {
476
    return getTreeView().getSelectionModel();
477
  }
478
479
  /**
480
   * Returns a copy of all the selected items.
481
   *
482
   * @return A list, possibly empty, containing all selected items in the
483
   * {@link TreeView}.
484
   */
485
  private List<TreeItem<String>> getSelectedItems() {
486
    return new ArrayList<>( getSelectionModel().getSelectedItems() );
487487
  }
488488
M src/main/java/com/scrivenvar/editors/EditorPane.java
2929
3030
import com.scrivenvar.AbstractPane;
31
32
import java.nio.file.Path;
33
import java.util.function.Consumer;
34
3531
import javafx.application.Platform;
3632
import javafx.beans.property.ObjectProperty;
3733
import javafx.beans.property.SimpleObjectProperty;
3834
import javafx.beans.value.ChangeListener;
3935
import javafx.event.Event;
4036
import javafx.scene.control.ScrollPane;
41
import javafx.scene.input.InputEvent;
4237
import org.fxmisc.flowless.VirtualizedScrollPane;
4338
import org.fxmisc.richtext.StyleClassedTextArea;
4439
import org.fxmisc.undo.UndoManager;
4540
import org.fxmisc.wellbehaved.event.EventPattern;
46
import org.fxmisc.wellbehaved.event.InputMap;
41
import org.fxmisc.wellbehaved.event.Nodes;
4742
48
import static org.fxmisc.wellbehaved.event.InputMap.consume;
43
import java.nio.file.Path;
44
import java.util.function.Consumer;
4945
50
import org.fxmisc.wellbehaved.event.Nodes;
46
import static org.fxmisc.wellbehaved.event.InputMap.consume;
5147
5248
/**
...
6258
      new VirtualizedScrollPane<>( mEditor );
6359
  private final ObjectProperty<Path> mPath = new SimpleObjectProperty<>();
64
65
  /**
66
   * Set when entering variable edit mode; retrieved upon exiting.
67
   */
68
  private InputMap<InputEvent> mNodeMap;
6960
7061
  public EditorPane() {
...
9485
9586
  public void setText( final String text ) {
96
    getEditor().deselect();
97
    getEditor().replaceText( text );
87
    final var editor = getEditor();
88
    editor.deselect();
89
    editor.replaceText( text );
9890
    getUndoManager().mark();
9991
  }
...
131123
      final Consumer<? super U> consumer ) {
132124
    Nodes.addInputMap( getEditor(), consume( event, consumer ) );
133
  }
134
135
  /**
136
   * This method adds listeners to editor events that can be removed without
137
   * affecting the original listeners (i.e., the original lister is restored on
138
   * a call to removeEventListener).
139
   *
140
   * @param map The map of methods to events.
141
   */
142
  @SuppressWarnings("unchecked")
143
  public void addEventListener( final InputMap<InputEvent> map ) {
144
    mNodeMap = (InputMap<InputEvent>) getInputMap();
145
    Nodes.addInputMap( getEditor(), map );
146
  }
147
148
  /**
149
   * This method removes listeners to editor events and restores the default
150
   * handler.
151
   *
152
   * @param map The map of methods to events.
153
   */
154
  public void removeEventListener( final InputMap<InputEvent> map ) {
155
    Nodes.removeInputMap( getEditor(), map );
156
    Nodes.addInputMap( getEditor(), mNodeMap );
157
  }
158
159
  /**
160
   * Returns the value for "org.fxmisc.wellbehaved.event.inputmap".
161
   *
162
   * @return An input map of input events.
163
   */
164
  private Object getInputMap() {
165
    return getEditor().getProperties().get( getInputMapKey() );
166
  }
167
168
  /**
169
   * Returns the hashmap key entry for the input map.
170
   *
171
   * @return "org.fxmisc.wellbehaved.event.inputmap"
172
   */
173
  private String getInputMapKey() {
174
    return "org.fxmisc.wellbehaved.event.inputmap";
175125
  }
176126
M src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
3131
import com.scrivenvar.dialogs.LinkDialog;
3232
import com.scrivenvar.editors.EditorPane;
33
import com.scrivenvar.processors.MarkdownProcessor;
33
import com.scrivenvar.processors.markdown.MarkdownProcessor;
3434
import com.vladsch.flexmark.ast.Link;
3535
import com.vladsch.flexmark.util.ast.Node;
M src/main/java/com/scrivenvar/preferences/FilePreferences.java
2828
package com.scrivenvar.preferences;
2929
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.events.Notifier;
32
3033
import java.io.File;
3134
import java.io.FileInputStream;
...
4447
 */
4548
public class FilePreferences extends AbstractPreferences {
49
  private final Notifier mNotifier = Services.load( Notifier.class );
4650
4751
  private final Map<String, String> mRoot = new TreeMap<>();
4852
  private final Map<String, FilePreferences> mChildren = new TreeMap<>();
4953
  private boolean mRemoved;
5054
5155
  private final Object mMutex = new Object();
5256
53
  public FilePreferences( final AbstractPreferences parent,
54
                          final String name ) {
57
  public FilePreferences(
58
      final AbstractPreferences parent, final String name ) {
5559
    super( parent, name );
5660
...
136140
137141
        final String path = getPath();
138
        final Enumeration<?> pnen = p.propertyNames();
142
        final Enumeration<?> propertyNames = p.propertyNames();
139143
140
        while( pnen.hasMoreElements() ) {
141
          final String propKey = (String) pnen.nextElement();
144
        while( propertyNames.hasMoreElements() ) {
145
          final String propKey = (String) propertyNames.nextElement();
142146
143147
          if( propKey.startsWith( path ) ) {
...
178182
179183
          // Make a list of all direct children of this node to be removed
180
          final Enumeration<?> pnen = p.propertyNames();
184
          final Enumeration<?> propertyNames = p.propertyNames();
181185
182
          while( pnen.hasMoreElements() ) {
183
            String propKey = (String) pnen.nextElement();
186
          while( propertyNames.hasMoreElements() ) {
187
            final String propKey = (String) propertyNames.nextElement();
184188
            if( propKey.startsWith( path ) ) {
185189
              final String subKey = propKey.substring( path.length() );
...
213217
214218
  private void error( final BackingStoreException ex ) {
215
    throw new RuntimeException( ex );
219
    getNotifier().notify( ex );
220
  }
221
222
  private Notifier getNotifier() {
223
    return mNotifier;
216224
  }
217225
}
A src/main/java/com/scrivenvar/preferences/UserPreferences.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.preferences;
29
30
import com.dlsc.formsfx.model.structure.StringField;
31
import com.dlsc.preferencesfx.PreferencesFx;
32
import com.dlsc.preferencesfx.model.Category;
33
import com.dlsc.preferencesfx.model.Group;
34
import com.dlsc.preferencesfx.model.Setting;
35
import com.scrivenvar.Services;
36
import com.scrivenvar.service.Settings;
37
import javafx.beans.property.ObjectProperty;
38
import javafx.beans.property.SimpleObjectProperty;
39
import javafx.beans.property.SimpleStringProperty;
40
import javafx.beans.property.StringProperty;
41
import javafx.scene.Node;
42
import javafx.scene.control.Label;
43
44
import java.io.File;
45
import java.nio.file.Path;
46
47
import static com.scrivenvar.Constants.PERSIST_IMAGES_DEFAULT;
48
import static com.scrivenvar.Constants.USER_DIRECTORY;
49
import static com.scrivenvar.Messages.get;
50
51
public class UserPreferences {
52
  private final Settings SETTINGS = Services.load( Settings.class );
53
54
  private final ObjectProperty<File> mPropRDirectory;
55
  private final StringProperty mPropRScript;
56
  private final ObjectProperty<File> mPropImagesDirectory;
57
  private final StringProperty mPropImagesOrder;
58
  private final ObjectProperty<File> mPropDefinitionPath;
59
60
  private final PreferencesFx mPreferencesFx;
61
62
  public UserPreferences() {
63
    mPropRDirectory = simpleFile( USER_DIRECTORY );
64
    mPropRScript = new SimpleStringProperty( "" );
65
66
    mPropImagesDirectory = simpleFile( USER_DIRECTORY );
67
    mPropImagesOrder = new SimpleStringProperty( PERSIST_IMAGES_DEFAULT );
68
69
    mPropDefinitionPath = simpleFile( getSetting(
70
        "file.definition.default", "variables.yaml" )
71
    );
72
73
    mPreferencesFx = createPreferencesFx();
74
  }
75
76
  /**
77
   * Display the user preferences settings dialog (non-modal).
78
   */
79
  public void show() {
80
    mPreferencesFx.show( false );
81
  }
82
83
  /**
84
   * Call to persist the settings. Strictly speaking, this could watch on
85
   * all values for external changes then save automatically.
86
   */
87
  public void save() {
88
    mPreferencesFx.saveSettings();
89
  }
90
91
  /**
92
   * Creates the preferences dialog.
93
   * <p>
94
   * TODO: Make this dynamic by iterating over all "Preferences.*" values
95
   * that follow a particular naming pattern.
96
   * </p>
97
   *
98
   * @return A new instance of preferences for users to edit.
99
   */
100
  @SuppressWarnings("unchecked")
101
  private PreferencesFx createPreferencesFx() {
102
    final Setting<StringField, StringProperty> scriptSetting =
103
        Setting.of( "Script", mPropRScript );
104
    final StringField field = scriptSetting.getElement();
105
    field.multiline( true );
106
107
    return PreferencesFx.of(
108
        UserPreferences.class,
109
        Category.of(
110
            get( "Preferences.r" ),
111
            Group.of(
112
                get( "Preferences.r.directory" ),
113
                Setting.of( label( "Preferences.r.directory.desc", false ) ),
114
                Setting.of( "Directory", mPropRDirectory, true )
115
            ),
116
            Group.of(
117
                get( "Preferences.r.script" ),
118
                Setting.of( label( "Preferences.r.script.desc" ) ),
119
                scriptSetting
120
            )
121
        ),
122
        Category.of(
123
            get( "Preferences.images" ),
124
            Group.of(
125
                get( "Preferences.images.directory" ),
126
                Setting.of( label( "Preferences.images.directory.desc" ) ),
127
                Setting.of( "Directory", mPropImagesDirectory, true )
128
            ),
129
            Group.of(
130
                get( "Preferences.images.suffixes" ),
131
                Setting.of( label( "Preferences.images.suffixes.desc" ) ),
132
                Setting.of( "Extensions", mPropImagesOrder )
133
            )
134
        ),
135
        Category.of(
136
            get( "Preferences.definitions" ),
137
            Group.of(
138
                get( "Preferences.definitions.path" ),
139
                Setting.of( label( "Preferences.definitions.path.desc" ) ),
140
                Setting.of( "Path", mPropDefinitionPath, false )
141
            )
142
        )
143
    );
144
  }
145
146
  /**
147
   * Wraps a {@link File} inside a {@link SimpleObjectProperty}.
148
   *
149
   * @param path The file name to use when constructing the {@link File}.
150
   * @return A new {@link SimpleObjectProperty} instance with a {@link File}
151
   * that references the given {@code path}.
152
   */
153
  private SimpleObjectProperty<File> simpleFile( final String path ) {
154
    return new SimpleObjectProperty<>( new File( path ) );
155
  }
156
157
  /**
158
   * Creates a label for the given key after interpolating its value.
159
   *
160
   * @param key The key to find in the resource bundle.
161
   * @return The value of the key as a label.
162
   */
163
  private Node label( final String key ) {
164
    return new Label( get( key, true ) );
165
  }
166
167
  /**
168
   * Creates a label for the given key.
169
   *
170
   * @param key         The key to find in the resource bundle.
171
   * @param interpolate {@code true} means to interpolate the value.
172
   * @return The value of the key, interpolated if {@code interpolate} is
173
   * {@code true}.
174
   */
175
  @SuppressWarnings("SameParameterValue")
176
  private Node label( final String key, final boolean interpolate ) {
177
    return new Label( get( key, interpolate ) );
178
  }
179
180
  /**
181
   * Returns the value for a key from the settings properties file.
182
   *
183
   * @param key   Key within the settings properties file to find.
184
   * @param value Default value to return if the key is not found.
185
   * @return The value for the given key from the settings file, or the
186
   * given {@code value} if no key found.
187
   */
188
  @SuppressWarnings("SameParameterValue")
189
  private String getSetting( final String key, final String value ) {
190
    return SETTINGS.getSetting( key, value );
191
  }
192
193
  public ObjectProperty<File> definitionPathProperty() {
194
    return mPropDefinitionPath;
195
  }
196
197
  public Path getDefinitionPath() {
198
    return definitionPathProperty().getValue().toPath();
199
  }
200
201
  private ObjectProperty<File> rDirectoryProperty() {
202
    return mPropRDirectory;
203
  }
204
205
  public File getRDirectory() {
206
    return rDirectoryProperty().getValue();
207
  }
208
209
  private StringProperty rScriptProperty() {
210
    return mPropRScript;
211
  }
212
213
  public String getRScript() {
214
    return rScriptProperty().getValue();
215
  }
216
217
  private ObjectProperty<File> imagesDirectoryProperty() {
218
    return mPropImagesDirectory;
219
  }
220
221
  public File getImagesDirectory() {
222
    return imagesDirectoryProperty().getValue();
223
  }
224
225
  private StringProperty imagesOrderProperty() {
226
    return mPropImagesOrder;
227
  }
228
229
  public String getImagesOrder() {
230
    return imagesOrderProperty().getValue();
231
  }
232
}
1233
A src/main/java/com/scrivenvar/preview/ChainedReplacedElementFactory.java
1
/*
2
 * {{{ header & license
3
 * Copyright (c) 2006 Patrick Wright
4
 * Copyright (c) 2007 Wisconsin Court System
5
 *
6
 * This program is free software; you can redistribute it and/or
7
 * modify it under the terms of the GNU Lesser General Public License
8
 * as published by the Free Software Foundation; either version 2.1
9
 * of the License, or (at your option) any later version.
10
 *
11
 * This program is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	See the
14
 * GNU Lesser General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU Lesser General Public License
17
 * along with this program; if not, write to the Free Software
18
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
19
 * }}}
20
 */
21
package com.scrivenvar.preview;
22
23
import org.w3c.dom.Element;
24
import org.xhtmlrenderer.extend.ReplacedElement;
25
import org.xhtmlrenderer.extend.ReplacedElementFactory;
26
import org.xhtmlrenderer.extend.UserAgentCallback;
27
import org.xhtmlrenderer.layout.LayoutContext;
28
import org.xhtmlrenderer.render.BlockBox;
29
import org.xhtmlrenderer.simple.extend.FormSubmissionListener;
30
31
import java.util.ArrayList;
32
import java.util.List;
33
34
public class ChainedReplacedElementFactory implements ReplacedElementFactory {
35
  private final List<ReplacedElementFactory> mFactoryList = new ArrayList<>();
36
37
  public ChainedReplacedElementFactory() {
38
  }
39
40
  public ReplacedElement createReplacedElement(
41
      LayoutContext c, BlockBox box, UserAgentCallback uac,
42
      int cssWidth, int cssHeight ) {
43
    ReplacedElement re = null;
44
45
    for( final ReplacedElementFactory ref : mFactoryList ) {
46
      re = ref.createReplacedElement( c, box, uac, cssWidth, cssHeight );
47
48
      if( re != null ) {
49
        break;
50
      }
51
    }
52
53
    return re;
54
  }
55
56
  public void addFactory( final ReplacedElementFactory factory ) {
57
    mFactoryList.add( factory );
58
  }
59
60
  public void reset() {
61
    for( final ReplacedElementFactory factory : mFactoryList ) {
62
      factory.reset();
63
    }
64
  }
65
66
  public void remove( final Element element ) {
67
    for( final ReplacedElementFactory factory : mFactoryList ) {
68
      factory.remove( element );
69
    }
70
  }
71
72
  public void setFormSubmissionListener( FormSubmissionListener listener ) {
73
  }
74
}
175
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
2828
package com.scrivenvar.preview;
2929
30
import static com.scrivenvar.Constants.CARET_POSITION_BASE;
31
import static com.scrivenvar.Constants.STYLESHEET_PREVIEW;
30
import javafx.embed.swing.SwingNode;
31
import javafx.scene.Node;
32
import javafx.scene.layout.Pane;
33
import org.jsoup.Jsoup;
34
import org.jsoup.helper.W3CDom;
35
import org.jsoup.nodes.Document;
36
import org.xhtmlrenderer.simple.XHTMLPanel;
37
import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler;
38
import org.xhtmlrenderer.swing.SwingReplacedElementFactory;
3239
40
import javax.swing.*;
3341
import java.nio.file.Path;
34
35
import javafx.beans.value.ObservableValue;
36
import javafx.concurrent.Worker.State;
37
38
import static javafx.concurrent.Worker.State.SUCCEEDED;
3942
40
import javafx.scene.Node;
41
import javafx.scene.layout.Pane;
42
import javafx.scene.web.WebEngine;
43
import javafx.scene.web.WebView;
43
import static com.scrivenvar.Constants.STYLESHEET_PREVIEW;
4444
4545
/**
4646
 * HTML preview pane is responsible for rendering an HTML document.
4747
 *
4848
 * @author Karl Tauber and White Magic Software, Ltd.
4949
 */
5050
public final class HTMLPreviewPane extends Pane {
51
  private static class HTMLPanel extends XHTMLPanel {
52
    /**
53
     * Prevent scrolling to the top.
54
     */
55
    @Override
56
    public void resetScrollPosition() {
57
    }
58
  }
5159
52
  private final WebView webView = new WebView();
53
  private Path path;
60
  private final static String HTML_HEADER = "<!DOCTYPE html>"
61
      + "<html>"
62
      + "<head>"
63
      + "<link rel='stylesheet' href='" +
64
      HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW ) + "'/>"
65
      + "</head>"
66
      + "<body>";
67
  private final static String HTML_FOOTER = "</body></html>";
68
69
  private final StringBuilder mHtml = new StringBuilder( 65536 );
70
  private final int mHtmlPrefixLength;
71
72
  private final W3CDom mW3cDom = new W3CDom();
73
  private final XhtmlNamespaceHandler mNamespaceHandler =
74
      new XhtmlNamespaceHandler();
75
  private final HTMLPanel mRenderer = new HTMLPanel();
76
  private final SwingNode mSwingNode = new SwingNode();
77
  private final JScrollPane mScrollPane = new JScrollPane( mRenderer );
78
79
  private Path mPath;
5480
5581
  /**
5682
   * Creates a new preview pane that can scroll to the caret position within the
5783
   * document.
5884
   */
5985
  public HTMLPreviewPane() {
60
    initListeners();
61
    initTraversal();
62
  }
63
64
  /**
65
   * Initializes observers for document changes. When the document is reloaded
66
   * with new HTML, this triggers a scroll event that repositions the document
67
   * to the injected caret (that corresponds with the position in the text
68
   * editor).
69
   */
70
  private void initListeners() {
71
    // Scrolls to the caret after the content has been loaded.
72
    getEngine().getLoadWorker().stateProperty().addListener(
73
        ( ObservableValue<? extends State> observable,
74
          final State oldValue, final State newValue ) -> {
75
          if( newValue == SUCCEEDED ) {
76
            scrollToCaret();
77
          }
78
        } );
79
  }
86
    final ChainedReplacedElementFactory factory =
87
        new ChainedReplacedElementFactory();
88
    factory.addFactory( new SVGReplacedElementFactory() );
89
    factory.addFactory( new SwingReplacedElementFactory() );
8090
81
  /**
82
   * Ensures images can be found relative to the document.
83
   *
84
   * @return The base path element to use for the document, or the empty string
85
   * if no path has been set, yet.
86
   */
87
  private String getBase() {
88
    final Path basePath = getPath();
89
    final Path parent = basePath == null ? null : basePath.getParent();
91
    mRenderer.getSharedContext().setReplacedElementFactory( factory );
92
    mRenderer.getSharedContext().getTextRenderer().setSmoothingThreshold( 0 );
93
    mSwingNode.setContent( mScrollPane );
9094
91
    return parent == null
92
        ? ""
93
        : ("<base href='" + parent.toUri().toString() + "'>");
95
    mHtml.append( HTML_HEADER );
96
    mHtmlPrefixLength = mHtml.length();
9497
  }
9598
9699
  /**
97100
   * Updates the internal HTML source, loads it into the preview pane, then
98101
   * scrolls to the caret position.
99102
   *
100103
   * @param html The new HTML document to display.
101104
   */
102105
  public void update( final String html ) {
103
    getEngine().loadContent(
104
        "<!DOCTYPE html>"
105
            + "<html>"
106
            + "<head>"
107
            + "<link rel='stylesheet' href='" + getClass().getResource(
108
            STYLESHEET_PREVIEW ) + "'>"
109
            + getBase()
110
            + "</head>"
111
            + "<body>"
112
            + html
113
            + "</body>"
114
            + "</html>" );
115
  }
116
117
  /**
118
   * Clears out the HTML content from the preview.
119
   */
120
  public void clear() {
121
    update( "" );
122
  }
106
    final Document jsoupDoc = Jsoup.parse( decorate( html ) );
107
    org.w3c.dom.Document w3cDoc = mW3cDom.fromJsoup( jsoupDoc );
123108
124
  /**
125
   * Scrolls to the caret position in the document.
126
   */
127
  private void scrollToCaret() {
128
    execute( getScrollScript() );
109
    mRenderer.setDocument( w3cDoc, getBaseUrl(), mNamespaceHandler );
129110
  }
130111
131
  /**
132
   * Returns the JavaScript used to scroll the WebView pane.
133
   *
134
   * @return A script that tries to center the view port on the CARET POSITION.
135
   */
136
  private String getScrollScript() {
137
    return ""
138
        + "var e = document.getElementById('" + CARET_POSITION_BASE + "');"
139
        + "if( e != null ) { "
140
        + "  Element.prototype.topOffset = function () {"
141
        + "    return this.offsetTop + (this.offsetParent ? this.offsetParent" +
142
        ".topOffset() : 0);"
143
        + "  };"
144
        + "  window.scrollTo( 0, e.topOffset() - (window.innerHeight / 2 ) );"
145
        + "}";
112
  private String decorate( final String html ) {
113
    mHtml.setLength( mHtmlPrefixLength );
114
    return mHtml.append( html )
115
                .append( HTML_FOOTER )
116
                .toString();
146117
  }
147118
148119
  /**
149
   * Prevent tabbing into the preview pane.
120
   * Clears out the HTML content from the preview.
150121
   */
151
  private void initTraversal() {
152
    getWebView().setFocusTraversable( false );
153
  }
154
155
  private void execute( final String script ) {
156
    getEngine().executeScript( script );
122
  public void clear() {
123
    update( "" );
157124
  }
158125
159
  private WebEngine getEngine() {
160
    return getWebView().getEngine();
161
  }
126
  private String getBaseUrl() {
127
    final Path basePath = getPath();
128
    final Path parent = basePath == null ? null : basePath.getParent();
162129
163
  private WebView getWebView() {
164
    return this.webView;
130
    return parent == null ? "" : parent.toUri().toString();
165131
  }
166132
167
  private Path getPath() {
168
    return this.path;
133
  public Path getPath() {
134
    return mPath;
169135
  }
170136
171137
  public void setPath( final Path path ) {
172138
    assert path != null;
173
174
    this.path = path;
139
    mPath = path;
175140
  }
176141
177142
  /**
178143
   * Content to embed in a panel.
179144
   *
180145
   * @return The content to display to the user.
181146
   */
182147
  public Node getNode() {
183
    return getWebView();
148
    return mSwingNode;
149
  }
150
151
  public JScrollPane getScrollPane() {
152
    return mScrollPane;
153
  }
154
155
  public JScrollBar getVerticalScrollBar() {
156
    return getScrollPane().getVerticalScrollBar();
184157
  }
185158
}
A src/main/java/com/scrivenvar/preview/SVGRasterizer.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.preview;
29
30
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
31
import org.apache.batik.gvt.renderer.ImageRenderer;
32
import org.apache.batik.transcoder.TranscoderException;
33
import org.apache.batik.transcoder.TranscoderInput;
34
import org.apache.batik.transcoder.TranscoderOutput;
35
import org.apache.batik.transcoder.image.ImageTranscoder;
36
import org.w3c.dom.svg.SVGDocument;
37
38
import java.awt.*;
39
import java.awt.image.BufferedImage;
40
import java.io.IOException;
41
import java.net.URL;
42
import java.util.Map;
43
44
import static java.awt.Color.WHITE;
45
import static java.awt.RenderingHints.*;
46
import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH;
47
import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_BACKGROUND_COLOR;
48
import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName;
49
50
public class SVGRasterizer {
51
  private final static SAXSVGDocumentFactory mFactory =
52
      new SAXSVGDocumentFactory( getXMLParserClassName() );
53
54
  private final static Map<Object, Object> RENDERING_HINTS = Map.of(
55
      KEY_ALPHA_INTERPOLATION,
56
      VALUE_ALPHA_INTERPOLATION_QUALITY,
57
      KEY_INTERPOLATION,
58
      VALUE_INTERPOLATION_BICUBIC,
59
      KEY_ANTIALIASING,
60
      VALUE_ANTIALIAS_ON,
61
      KEY_COLOR_RENDERING,
62
      VALUE_COLOR_RENDER_QUALITY,
63
      KEY_DITHERING,
64
      VALUE_DITHER_DISABLE,
65
      KEY_RENDERING,
66
      VALUE_RENDER_QUALITY,
67
      KEY_STROKE_CONTROL,
68
      VALUE_STROKE_PURE,
69
      KEY_FRACTIONALMETRICS,
70
      VALUE_FRACTIONALMETRICS_ON,
71
      KEY_TEXT_ANTIALIASING,
72
      VALUE_TEXT_ANTIALIAS_OFF
73
  );
74
75
  private static class BufferedImageTranscoder extends ImageTranscoder {
76
    private BufferedImage mImage;
77
78
    @Override
79
    public BufferedImage createImage( final int w, final int h ) {
80
      return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB );
81
    }
82
83
    @Override
84
    public void writeImage(
85
        final BufferedImage image, final TranscoderOutput output ) {
86
      mImage = image;
87
    }
88
89
    public BufferedImage getBufferedImage() {
90
      return mImage;
91
    }
92
93
    @Override
94
    protected ImageRenderer createRenderer() {
95
      final ImageRenderer renderer = super.createRenderer();
96
      final RenderingHints hints = renderer.getRenderingHints();
97
      hints.putAll( RENDERING_HINTS );
98
99
      renderer.setRenderingHints( hints );
100
101
      return renderer;
102
    }
103
  }
104
105
  public static BufferedImage rasterize( final String url, final int width )
106
      throws IOException, TranscoderException {
107
    return rasterize( new URL( url ), width );
108
  }
109
110
  public static BufferedImage rasterize( final URL url, final int width )
111
      throws IOException, TranscoderException {
112
    return rasterize(
113
        (SVGDocument) mFactory.createDocument( url.toString() ), width );
114
  }
115
116
  public static BufferedImage rasterize(
117
      final SVGDocument svg, final int width ) throws TranscoderException {
118
    final var transcoder = new BufferedImageTranscoder();
119
    final var input = new TranscoderInput( svg );
120
121
    transcoder.addTranscodingHint( KEY_BACKGROUND_COLOR, WHITE );
122
    transcoder.addTranscodingHint( KEY_WIDTH, (float) width );
123
    transcoder.transcode( input, null );
124
125
    return transcoder.getBufferedImage();
126
  }
127
}
1128
A src/main/java/com/scrivenvar/preview/SVGReplacedElementFactory.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.preview;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.events.Notifier;
32
import org.apache.commons.io.FilenameUtils;
33
import org.w3c.dom.Element;
34
import org.xhtmlrenderer.extend.ReplacedElement;
35
import org.xhtmlrenderer.extend.ReplacedElementFactory;
36
import org.xhtmlrenderer.extend.UserAgentCallback;
37
import org.xhtmlrenderer.layout.LayoutContext;
38
import org.xhtmlrenderer.render.BlockBox;
39
import org.xhtmlrenderer.simple.extend.FormSubmissionListener;
40
import org.xhtmlrenderer.swing.ImageReplacedElement;
41
42
import java.awt.*;
43
44
public class SVGReplacedElementFactory
45
    implements ReplacedElementFactory {
46
47
  private final static Notifier sNotifier = Services.load( Notifier.class );
48
49
  /**
50
   * SVG filename extension.
51
   */
52
  private static final String SVG_FILE = "svg";
53
  private static final String HTML_IMAGE = "img";
54
  private static final String HTML_IMAGE_SRC = "src";
55
56
  public ReplacedElement createReplacedElement(
57
      final LayoutContext c, final BlockBox box, final UserAgentCallback uac,
58
      final int cssWidth, final int cssHeight ) {
59
    final Element e = box.getElement();
60
61
    if( e == null ) {
62
      return null;
63
    }
64
65
    final String nodeName = e.getNodeName();
66
    ReplacedElement result = null;
67
68
    if( HTML_IMAGE.equals( nodeName ) ) {
69
      final String src = e.getAttribute( HTML_IMAGE_SRC );
70
      final String ext = FilenameUtils.getExtension( src );
71
72
      if( SVG_FILE.equalsIgnoreCase( ext ) ) {
73
        try {
74
          final int width = box.getContentWidth();
75
          final Image image = SVGRasterizer.rasterize( src, width );
76
77
          final int w = image.getWidth( null );
78
          final int h = image.getHeight( null );
79
80
          result = new ImageReplacedElement( image, w, h );
81
        } catch( final Exception ex ) {
82
          getNotifier().notify( ex );
83
        }
84
      }
85
    }
86
87
    return result;
88
  }
89
90
  @Override
91
  public void reset() {
92
  }
93
94
  @Override
95
  public void remove( Element e ) {
96
  }
97
98
  @Override
99
  public void setFormSubmissionListener( FormSubmissionListener listener ) {
100
  }
101
102
  private Notifier getNotifier() {
103
    return sNotifier;
104
  }
105
}
1106
M src/main/java/com/scrivenvar/processors/AbstractProcessor.java
3939
public abstract class AbstractProcessor<T> implements Processor<T> {
4040
41
  protected static final char NEWLINE = '\n';
42
43
  /**
44
   * When performing string searches using indexOf, a return value of -1
45
   * indicates that the string could not be found.
46
   */
47
  protected static final int INDEX_NOT_FOUND = -1;
48
4941
  /**
5042
   * Used while processing the entire chain; null to signify no more links.
D src/main/java/com/scrivenvar/processors/CaretInsertionProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import static com.scrivenvar.Constants.CARET_POSITION_MD;
31
import javafx.beans.property.IntegerProperty;
32
import javafx.beans.property.SimpleIntegerProperty;
33
import javafx.beans.value.ObservableValue;
34
35
/**
36
 * Base class for inserting the magic CARET POSITION into the text so that, upon
37
 * previewing, the preview pane can scroll to the correct position (relative to
38
 * the caret position in the editor).
39
 *
40
 * @author White Magic Software, Ltd.
41
 */
42
public abstract class CaretInsertionProcessor extends AbstractProcessor<String> {
43
44
  private final IntegerProperty caretPosition = new SimpleIntegerProperty();
45
  private final static String NEWLINE_CARET_POSITION_MD = NEWLINE + CARET_POSITION_MD;
46
47
  public CaretInsertionProcessor(
48
    final Processor<String> processor,
49
    final ObservableValue<Integer> position ) {
50
    super( processor );
51
    this.caretPosition.bind( position );
52
  }
53
54
  /**
55
   * Inserts the caret position token into the text at an offset that won't
56
   * interfere with parsing the text itself, regardless of text format.
57
   *
58
   * @param text The text document to change.
59
   * @param i The caret position token insertion point to use, or -1 to return
60
   * the text without any injection.
61
   *
62
   * @return The given text with a caret position token inserted at the given
63
   * offset.
64
   */
65
  protected String inject( final String text, final int i ) {
66
    if( i > 0 && i <= text.length() ) {
67
      // Preserve the newline character when inserting the caret position mark.
68
      final String replacement = text.charAt( i - 1 ) == NEWLINE
69
        ? NEWLINE_CARET_POSITION_MD
70
        : CARET_POSITION_MD;
71
72
      return new StringBuilder( text ).replace( i, i, replacement ).toString();
73
    }
74
75
    return text;
76
  }
77
78
  /**
79
   * Returns true if i is greater than or equal to min and less than or equal to
80
   * max.
81
   *
82
   * @param i The value to check.
83
   * @param min The lower bound.
84
   * @param max The upper bound.
85
   *
86
   * @return false The value of i is either lower than min or greater than max.
87
   */
88
  protected boolean isBetween( int i, int min, int max ) {
89
    return i >= min && i <= max;
90
  }
91
92
  /**
93
   * Returns the editor's caret position.
94
   *
95
   * @return Where the user has positioned the caret.
96
   */
97
  protected int getCaretPosition() {
98
    return this.caretPosition.getValue();
99
  }
100
}
1011
D src/main/java/com/scrivenvar/processors/CaretReplacementProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import static com.scrivenvar.Constants.CARET_POSITION_HTML;
31
import static com.scrivenvar.Constants.CARET_POSITION_MD;
32
33
/**
34
 * Responsible for replacing the caret position marker with an HTML element
35
 * suitable to use as a reference for scrolling a view port.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public class CaretReplacementProcessor extends AbstractProcessor<String> {
40
41
  public CaretReplacementProcessor( final Processor<String> processor ) {
42
    super( processor );
43
  }
44
45
  /**
46
   * Replaces each MD_CARET_POSITION with an HTML element that has an id
47
   * attribute of CARET_POSITION. This should only replace one item.
48
   *
49
   * @param t The text that contains
50
   * @return The value of the first instance replaced.
51
   */
52
  @Override
53
  public String processLink( final String t ) {
54
    return replace( t, CARET_POSITION_MD, CARET_POSITION_HTML );
55
  }
56
57
  /**
58
   * Replaces the needle with thread in the given haystack. Based on Apache
59
   * Commons 3 StringUtils.replace method. Should be faster than String.replace,
60
   * which performs a little regex under the hood.
61
   *
62
   * @param haystack Search this string for the needle, must not be null.
63
   * @param needle   The text to find in the haystack.
64
   * @param thread   Replace the needle with this text, if the needle is found.
65
   * @return The haystack with the first instance of needle replaced with
66
   * thread.
67
   */
68
  @SuppressWarnings("SameParameterValue")
69
  private static String replace(
70
      final String haystack, final String needle, final String thread ) {
71
    final int end = haystack.indexOf( needle );
72
73
    return end == INDEX_NOT_FOUND ?
74
        haystack :
75
        haystack.substring( 0, end ) + thread +
76
            haystack.substring( end + needle.length() );
77
  }
78
}
791
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
2929
3030
import com.scrivenvar.Services;
31
import com.scrivenvar.preferences.UserPreferences;
3132
import com.scrivenvar.service.Options;
3233
import com.scrivenvar.service.events.Notifier;
33
import org.renjin.eval.EvalException;
3434
3535
import javax.script.ScriptEngine;
3636
import javax.script.ScriptEngineManager;
3737
import javax.script.ScriptException;
3838
import java.nio.file.Path;
39
import java.util.LinkedHashMap;
3940
import java.util.Map;
4041
...
5657
  private static final Options OPTIONS = Services.load( Options.class );
5758
58
  // Only one editor is open at a time.
59
  /**
60
   * Constrain memory when typing new R expressions into the document.
61
   */
62
  private static final int MAX_CACHED_R_STATEMENTS = 512;
63
64
  /**
65
   * Only one editor is open at a time.
66
   */
5967
  private static final ScriptEngine ENGINE =
6068
      (new ScriptEngineManager()).getEngineByName( "Renjin" );
69
70
  /**
71
   * Where to put document inline evaluated R expressions.
72
   */
73
  private final Map<String, Object> mEvalCache = new LinkedHashMap<>() {
74
    @Override
75
    protected boolean removeEldestEntry(
76
        final Map.Entry<String, Object> eldest ) {
77
      return size() > MAX_CACHED_R_STATEMENTS;
78
    }
79
  };
6180
6281
  /**
...
83102
      map.put( "$application.r.working.directory$", dir );
84103
85
      final String initScript = getInitScript();
104
      final String bootstrap = getBootstrapScript();
86105
87
      if( !initScript.isBlank() ) {
88
        eval( replace( initScript, map ) );
106
      if( !bootstrap.isBlank() ) {
107
        eval( replace( bootstrap, map ) );
89108
      }
90109
    } catch( final Exception e ) {
91110
      getNotifier().notify( e );
92111
    }
93
  }
94
95
  /**
96
   * Loads the R init script from the application's persisted preferences.
97
   *
98
   * @return A non-null String, possibly empty.
99
   */
100
  private String getInitScript() {
101
    return getOptions().get( PERSIST_R_STARTUP, "" );
102112
  }
103113
...
116126
    final int prefixLength = PREFIX.length();
117127
118
    // Pre-allocate the same amount of space. A calculation is longer to write
119
    // than its computed value inserted into the text.
120
    final StringBuilder sb = new StringBuilder( length );
128
    // The * 2 is a wild guess at the ratio of R statements to the length
129
    // of text produced by those statements.
130
    final StringBuilder sb = new StringBuilder( length * 2 );
121131
122132
    int prevIndex = 0;
...
133143
      currIndex = text.indexOf( SUFFIX, min( currIndex + 1, length ) );
134144
135
      // Only evalutate inline R statements that have end delimiters.
145
      // Only evaluate inline R statements that have end delimiters.
136146
      if( currIndex > 1 ) {
137147
        // Extract the inline R statement to be evaluated.
138148
        final String r = text.substring( prevIndex, currIndex );
139149
140150
        // Pass the R statement into the R engine for evaluation.
141151
        try {
142
          final Object result = eval( r );
152
          final Object result = evalText( r );
143153
144154
          // Append the string representation of the result into the text.
...
168178
169179
  /**
170
   * Evaluate an R expression and return the resulting object.
180
   * Look up an R expression from the cache then return the resulting object.
181
   * If the R expression hasn't been cached, it'll first be evalulated.
171182
   *
172183
   * @param r The expression to evaluate.
173184
   * @return The object resulting from the evaluation.
174185
   */
175
  private Object eval( final String r ) throws ScriptException, EvalException {
176
    return getScriptEngine().eval( r );
177
  }
178
179
  private synchronized ScriptEngine getScriptEngine() {
180
    return ENGINE;
181
  }
182
183
  private Notifier getNotifier() {
184
    return NOTIFIER;
186
  private Object evalText( final String r ) {
187
    return mEvalCache.computeIfAbsent( r, v -> eval( r ) );
185188
  }
186189
187
  private Options getOptions() {
188
    return OPTIONS;
190
  /**
191
   * Evaluate an R expression and return the resulting object.
192
   *
193
   * @param r The expression to evaluate.
194
   * @return The object resulting from the evaluation.
195
   */
196
  private Object eval( final String r ) {
197
    try {
198
      return getScriptEngine().eval( r );
199
    } catch( final ScriptException e ) {
200
      getNotifier().notify( e );
201
      return "";
202
    }
189203
  }
190204
191205
  /**
192206
   * This will return the given path if not null, otherwise it will return
193207
   * the path to the user's directory.
194208
   *
195209
   * @return A non-null path.
196210
   */
197211
  private Path getWorkingDirectory() {
198
    return Path.of( getPreference( PERSIST_R_DIRECTORY, USER_DIRECTORY ) );
212
    return getUserPreferences().getRDirectory().toPath();
199213
  }
200214
201215
  /**
202
   * Returns the user-defined preference value for the given key.
216
   * Loads the R init script from the application's persisted preferences.
203217
   *
204
   * @param key          The key to find in the user's preferences.
205
   * @param defaultValue The default value to return if no preference is set.
206
   * @return The value for the preference, or {@code defaultValue} if not found.
218
   * @return A non-null String, possibly empty.
207219
   */
208
  @SuppressWarnings("SameParameterValue")
209
  private String getPreference( final String key, final String defaultValue ) {
210
    return OPTIONS.get( key, defaultValue );
220
  private String getBootstrapScript() {
221
    return getUserPreferences().getRScript();
222
  }
223
224
  private UserPreferences getUserPreferences() {
225
    return getOptions().getUserPreferences();
226
  }
227
228
  private ScriptEngine getScriptEngine() {
229
    return ENGINE;
230
  }
231
232
  private Notifier getNotifier() {
233
    return NOTIFIER;
234
  }
235
236
  private Options getOptions() {
237
    return OPTIONS;
211238
  }
212239
}
D src/main/java/com/scrivenvar/processors/MarkdownCaretInsertionProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import static java.lang.Character.isLetter;
31
import static java.lang.Math.min;
32
33
import javafx.beans.value.ObservableValue;
34
35
/**
36
 * Responsible for inserting a caret position token into a markdown document.
37
 *
38
 * @author White Magic Software, Ltd.
39
 */
40
public class MarkdownCaretInsertionProcessor extends CaretInsertionProcessor {
41
42
  /**
43
   * Constructs a processor capable of inserting a caret marker into Markdown.
44
   *
45
   * @param processor The next processor in the chain.
46
   * @param position  The caret's current position in the text.
47
   */
48
  public MarkdownCaretInsertionProcessor(
49
      final Processor<String> processor,
50
      final ObservableValue<Integer> position ) {
51
    super( processor, position );
52
  }
53
54
  /**
55
   * Changes the text to insert a "caret" at the caret position. This will
56
   * insert the unique key of Constants.MD_CARET_POSITION into the document.
57
   *
58
   * @param t The text document to process.
59
   * @return The text with the caret position token inserted at the caret
60
   * position.
61
   */
62
  @Override
63
  public String processLink( final String t ) {
64
    final int length = t.length();
65
    int offset = min( getCaretPosition(), length );
66
67
    // TODO: Ensure that the caret position is outside of an element, 
68
    // so that a caret inserted in the image doesn't corrupt it. Such as:
69
    //
70
    // ![Screenshot](images/scr|eenshot.png)
71
    //
72
    // 1. Scan back to the previous EOL, which will be the MD AST start point.
73
    // 2. Scan forward until EOF or EOL, which will be the MD AST ending point.
74
    // 3. Convert the text between start and end into MD AST.
75
    // 4. Find the nearest text node to the caret.
76
    // 5. Insert the CARET_POSITION_MD value in the text at that offsset.
77
    // Insert the caret at the closest non-Markdown delimiter (i.e., the 
78
    // closest character from the caret position forward).
79
    while( offset < length && !isLetter( t.charAt( offset ) ) ) {
80
      offset++;
81
    }
82
83
    return inject( t, offset );
84
  }
85
}
861
D src/main/java/com/scrivenvar/processors/MarkdownProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
31
import com.vladsch.flexmark.ext.superscript.SuperscriptExtension;
32
import com.vladsch.flexmark.ext.tables.TablesExtension;
33
import com.vladsch.flexmark.html.HtmlRenderer;
34
import com.vladsch.flexmark.parser.Parser;
35
import com.vladsch.flexmark.util.ast.Node;
36
import com.vladsch.flexmark.util.misc.Extension;
37
38
import java.util.ArrayList;
39
import java.util.Collection;
40
41
/**
42
 * Responsible for parsing a Markdown document and rendering it as HTML.
43
 *
44
 * @author White Magic Software, Ltd.
45
 */
46
public class MarkdownProcessor extends AbstractProcessor<String> {
47
48
  private final static HtmlRenderer RENDERER;
49
  private final static Parser PARSER;
50
51
  static {
52
    final Collection<Extension> extensions = new ArrayList<>();
53
    extensions.add( TablesExtension.create() );
54
    extensions.add( SuperscriptExtension.create() );
55
    extensions.add( StrikethroughSubscriptExtension.create() );
56
57
    RENDERER = HtmlRenderer.builder().extensions( extensions ).build();
58
    PARSER = Parser.builder().extensions( extensions ).build();
59
  }
60
61
  /**
62
   * Constructs a new Markdown processor that can create HTML documents.
63
   *
64
   * @param successor Usually the HTML Preview Processor.
65
   */
66
  public MarkdownProcessor( final Processor<String> successor ) {
67
    super( successor );
68
  }
69
70
  /**
71
   * Converts the given Markdown string into HTML, without the doctype, html,
72
   * head, and body tags.
73
   *
74
   * @param markdown The string to convert from Markdown to HTML.
75
   * @return The HTML representation of the Markdown document.
76
   */
77
  @Override
78
  public String processLink( final String markdown ) {
79
    return toHtml( markdown );
80
  }
81
82
  /**
83
   * Returns the AST in the form of a node for the given markdown document. This
84
   * can be used, for example, to determine if a hyperlink exists inside of a
85
   * paragraph.
86
   *
87
   * @param markdown The markdown to convert into an AST.
88
   * @return The markdown AST for the given text (usually a paragraph).
89
   */
90
  public Node toNode( final String markdown ) {
91
    return parse( markdown );
92
  }
93
94
  /**
95
   * Helper method to create an AST given some markdown.
96
   *
97
   * @param markdown The markdown to parse.
98
   * @return The root node of the markdown tree.
99
   */
100
  private Node parse( final String markdown ) {
101
    return getParser().parse( markdown );
102
  }
103
104
  /**
105
   * Converts a string of markdown into HTML.
106
   *
107
   * @param markdown The markdown text to convert to HTML, must not be null.
108
   * @return The markdown rendered as an HTML document.
109
   */
110
  private String toHtml( final String markdown ) {
111
    return getRenderer().render( parse( markdown ) );
112
  }
113
114
  /**
115
   * Creates the Markdown document processor.
116
   *
117
   * @return A Parser that can build an abstract syntax tree.
118
   */
119
  private Parser getParser() {
120
    return PARSER;
121
  }
122
123
  private HtmlRenderer getRenderer() {
124
    return RENDERER;
125
  }
126
}
1271
M src/main/java/com/scrivenvar/processors/ProcessorFactory.java
3131
import com.scrivenvar.FileEditorTab;
3232
import com.scrivenvar.preview.HTMLPreviewPane;
33
import javafx.beans.value.ObservableValue;
33
import com.scrivenvar.processors.markdown.MarkdownProcessor;
3434
3535
import java.nio.file.Path;
...
4646
  private final HTMLPreviewPane mPreviewPane;
4747
  private final Map<String, String> mResolvedMap;
48
  private final Processor<String> mCommonProcessor;
48
  private final Processor<String> mMarkdownProcessor;
4949
5050
  /**
...
6060
    mPreviewPane = previewPane;
6161
    mResolvedMap = resolvedMap;
62
    mCommonProcessor = createCommonProcessor();
62
    mMarkdownProcessor = createMarkdownProcessor();
6363
  }
6464
...
7676
    switch( lookup( path ) ) {
7777
      case RMARKDOWN:
78
        processor = createRProcessor( tab );
78
        processor = createRProcessor();
7979
        break;
8080
8181
      case SOURCE:
82
        processor = createMarkdownProcessor( tab );
82
        processor = createMarkdownDefinitionProcessor();
8383
        break;
8484
...
9797
9898
    return processor;
99
  }
100
101
  private Processor<String> createHTMLPreviewProcessor() {
102
    return new HTMLPreviewProcessor( getPreviewPane() );
99103
  }
100104
101105
  /**
102106
   * Creates and links the processors at the end of the processing chain.
103107
   *
104108
   * @return A markdown, caret replacement, and preview pane processor chain.
105109
   */
106
  private Processor<String> createCommonProcessor() {
107
    final var hpp = new HTMLPreviewProcessor( getPreviewPane() );
108
    final var mcrp = new CaretReplacementProcessor( hpp );
109
110
    return new MarkdownProcessor( mcrp );
110
  private Processor<String> createMarkdownProcessor() {
111
    final var hpp = createHTMLPreviewProcessor();
112
    return new MarkdownProcessor( hpp, getPreviewPane().getPath() );
111113
  }
112114
113115
  protected Processor<String> createIdentityProcessor() {
114
    final var hpp = new HTMLPreviewProcessor( getPreviewPane() );
115
116
    final var hpp = createHTMLPreviewProcessor();
116117
    return new IdentityProcessor( hpp );
117118
  }
118119
119120
  protected Processor<String> createDefinitionProcessor(
120121
      final Processor<String> p ) {
121122
    return new DefinitionProcessor( p, getResolvedMap() );
122123
  }
123124
124
  protected Processor<String> createMarkdownProcessor(
125
      final FileEditorTab tab ) {
126
    final var caret = tab.caretPositionProperty();
125
  protected Processor<String> createMarkdownDefinitionProcessor() {
127126
    final var tpc = getCommonProcessor();
128
    final var cip = createMarkdownInsertionProcessor( tpc, caret );
129
130
    return createDefinitionProcessor( cip );
127
    return createDefinitionProcessor( tpc );
131128
  }
132129
133130
  protected Processor<String> createXMLProcessor( final FileEditorTab tab ) {
134
    final var caret = tab.caretPositionProperty();
135131
    final var tpc = getCommonProcessor();
136132
    final var xmlp = new XMLProcessor( tpc, tab.getPath() );
137
    final var dp = createDefinitionProcessor( xmlp );
138
139
    return createXMLInsertionProcessor( dp, caret );
133
    return createDefinitionProcessor( xmlp );
140134
  }
141135
142
  protected Processor<String> createRProcessor( final FileEditorTab tab ) {
143
    final var caret = tab.caretPositionProperty();
136
  protected Processor<String> createRProcessor() {
144137
    final var tpc = getCommonProcessor();
145138
    final var rp = new InlineRProcessor( tpc, getResolvedMap() );
146
    final var rvp = new RVariableProcessor( rp, getResolvedMap() );
147
148
    return createRInsertionProcessor( rvp, caret );
139
    return new RVariableProcessor( rp, getResolvedMap() );
149140
  }
150141
151142
  protected Processor<String> createRXMLProcessor( final FileEditorTab tab ) {
152
    final var caret = tab.caretPositionProperty();
153143
    final var tpc = getCommonProcessor();
154144
    final var xmlp = new XMLProcessor( tpc, tab.getPath() );
155145
    final var rp = new InlineRProcessor( xmlp, getResolvedMap() );
156
    final var rvp = new RVariableProcessor( rp, getResolvedMap() );
157
158
    return createXMLInsertionProcessor( rvp, caret );
159
  }
160
161
  private Processor<String> createMarkdownInsertionProcessor(
162
      final Processor<String> tpc, final ObservableValue<Integer> caret ) {
163
    return new MarkdownCaretInsertionProcessor( tpc, caret );
164
  }
165
166
  /**
167
   * Create an insertion processor that is aware of R statements and will insert
168
   * a caret outside of any statement the caret falls within.
169
   *
170
   * @param processor Another link in the processor chain.
171
   * @param caret     The caret insertion point.
172
   * @return A processor that can insert a caret token without disturbing any R
173
   * code.
174
   */
175
  private Processor<String> createRInsertionProcessor(
176
      final Processor<String> processor,
177
      final ObservableValue<Integer> caret ) {
178
    return new RMarkdownCaretInsertionProcessor( processor, caret );
179
  }
180
181
  private Processor<String> createXMLInsertionProcessor(
182
      final Processor<String> tpc, final ObservableValue<Integer> caret ) {
183
    return new XMLCaretInsertionProcessor( tpc, caret );
146
    return new RVariableProcessor( rp, getResolvedMap() );
184147
  }
185148
...
204167
   */
205168
  private Processor<String> getCommonProcessor() {
206
    return mCommonProcessor;
169
    return mMarkdownProcessor;
207170
  }
208171
}
D src/main/java/com/scrivenvar/processors/RMarkdownCaretInsertionProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import static com.scrivenvar.decorators.RVariableDecorator.PREFIX;
31
import static com.scrivenvar.decorators.RVariableDecorator.SUFFIX;
32
import static java.lang.Integer.max;
33
34
import javafx.beans.value.ObservableValue;
35
36
/**
37
 * Responsible for inserting a caret position token into an R document.
38
 *
39
 * @author White Magic Software, Ltd.
40
 */
41
public class RMarkdownCaretInsertionProcessor
42
    extends MarkdownCaretInsertionProcessor {
43
44
  /**
45
   * Constructs a processor capable of inserting a caret marker into Markdown.
46
   *
47
   * @param processor The next processor in the chain.
48
   * @param position  The caret's current position in the text.
49
   */
50
  public RMarkdownCaretInsertionProcessor(
51
      final Processor<String> processor,
52
      final ObservableValue<Integer> position ) {
53
    super( processor, position );
54
  }
55
56
  /**
57
   * Changes the text to insert a "caret" at the caret position. This will
58
   * insert the unique key of Constants.MD_CARET_POSITION into the document.
59
   *
60
   * @param text The text document to process.
61
   * @return The text with the caret position token inserted at the caret
62
   * position.
63
   */
64
  @Override
65
  public String processLink( final String text ) {
66
    int offset = getCaretPosition();
67
68
    // Search for inline R code from the start of the caret's paragraph.
69
    // This should be much faster than scanning text from the beginning.
70
    int index = text.lastIndexOf( NEWLINE, offset );
71
72
    if( index == INDEX_NOT_FOUND ) {
73
      index = 0;
74
    }
75
76
    // Scan for an inline R statement, either from the nearest paragraph or
77
    // the beginning of the file, whichever was found first.
78
    index = text.indexOf( PREFIX, index );
79
80
    // If there was no R prefix then insert at the caret's initial offset...
81
    if( index != INDEX_NOT_FOUND ) {
82
      // Otherwise, retain the starting index of the first R statement in the
83
      // paragraph.
84
      int rPrefix = index + 1;
85
86
      // Scan for inline R prefixes until the text is exhausted or indexed
87
      // beyond the caret position.
88
      while( index != INDEX_NOT_FOUND && index < offset ) {
89
        // Set rPrefix to the index that might precede the caret. The + 1 is
90
        // to skip passed the leading backtick in the prefix (`r#).
91
        rPrefix = index + 1;
92
93
        // If there are no more R prefixes, exit the loop and look for a
94
        // suffix starting from the rPrefix position.
95
        index = text.indexOf( PREFIX, rPrefix );
96
      }
97
98
      // Scan from the character after the R prefix up to any R suffix.
99
      final int rSuffix = max( text.indexOf( SUFFIX, rPrefix ), rPrefix );
100
101
      // If the caret falls between the rPrefix and rSuffix, then change the
102
      // insertion point.
103
      final boolean between = isBetween( offset, rPrefix, rSuffix );
104
105
      // Insert the caret marker at the start of the R statement.
106
      if( between ) {
107
        offset = rPrefix - 1;
108
      }
109
    }
110
111
    return inject( text, offset );
112
  }
113
}
1141
D src/main/java/com/scrivenvar/processors/XMLCaretInsertionProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import com.ximpleware.VTDException;
31
import com.ximpleware.VTDGen;
32
import static com.ximpleware.VTDGen.TOKEN_CHARACTER_DATA;
33
import com.ximpleware.VTDNav;
34
import static java.nio.charset.StandardCharsets.UTF_8;
35
import java.text.ParseException;
36
import javafx.beans.value.ObservableValue;
37
38
/**
39
 * Inserts a caret position indicator into the document.
40
 *
41
 * @author White Magic Software, Ltd.
42
 */
43
public class XMLCaretInsertionProcessor extends CaretInsertionProcessor {
44
45
  private final static VTDGen PARSER = new VTDGen();
46
47
  /**
48
   * Constructs a processor capable of inserting a caret marker into XML.
49
   *
50
   * @param processor The next processor in the chain.
51
   * @param position The caret's current position in the text, cannot be null.
52
   */
53
  public XMLCaretInsertionProcessor(
54
    final Processor<String> processor,
55
    final ObservableValue<Integer> position ) {
56
    super( processor, position );
57
  }
58
59
  /**
60
   * Inserts a caret at a valid position within the XML document.
61
   *
62
   * @param text The string into which caret position marker text is inserted.
63
   *
64
   * @return The text with a caret position marker included, or the original
65
   * text if no insertion point could be found.
66
   */
67
  @Override
68
  public String processLink( final String text ) {
69
    final int caret = getCaretPosition();
70
    int insertOffset = -1;
71
72
    if( text.length() > 0 ) {
73
      try {
74
        final VTDNav vn = getNavigator( text );
75
        final int tokens = vn.getTokenCount();
76
77
        int currTokenIndex = 0;
78
        int prevTokenIndex = currTokenIndex;
79
        int currOffset = 0;
80
81
        // To find the insertion spot even faster, the algorithm could
82
        // use a binary search or interpolation search algorithm. This
83
        // would reduce the worst-case iterations to O(log n) from O(n).
84
        while( currTokenIndex < tokens ) {
85
          if( vn.getTokenType( currTokenIndex ) == TOKEN_CHARACTER_DATA ) {
86
            final int prevOffset = currOffset;
87
            currOffset = vn.getTokenOffset( currTokenIndex );
88
89
            if( currOffset > caret ) {
90
              final int prevLength = vn.getTokenLength( prevTokenIndex );
91
92
              // If the caret falls within the limits of the previous token,
93
              // theninsert the caret position marker at the caret offset.
94
              if( isBetween( caret, prevOffset, prevOffset + prevLength ) ) {
95
                insertOffset = caret;
96
              } else {
97
                // The caret position is outside the previous token's text
98
                // boundaries, but not inside the current text token. The
99
                // caret should be positioned into the closer text token.
100
                // For now, the cursor is positioned at the start of the
101
                // current text token.
102
                insertOffset = currOffset;
103
              }
104
105
              break;
106
            }
107
108
            prevTokenIndex = currTokenIndex;
109
          }
110
111
          currTokenIndex++;
112
        }
113
114
      } catch( final Exception ex ) {
115
        throw new RuntimeException(
116
          new ParseException( ex.getMessage(), caret )
117
        );
118
      }
119
    }
120
121
    return inject( text, insertOffset );
122
  }
123
124
  /**
125
   * Parses the given XML document and returns a high-performance navigator
126
   * instance for scanning through the XML elements.
127
   *
128
   * @param xml The XML document to parse.
129
   *
130
   * @return A document navigator instance.
131
   */
132
  private VTDNav getNavigator( final String xml ) throws VTDException {
133
    final VTDGen vg = getParser();
134
135
    // XML recommends UTF-8 encoding.
136
    // See: http://stackoverflow.com/a/36696214/59087
137
    //
138
    // The encoding should be derived, not assumed.
139
    vg.setDoc( xml.getBytes( UTF_8 ) );
140
    vg.parse( true );
141
    return vg.getNav();
142
  }
143
144
  private synchronized VTDGen getParser() {
145
    return PARSER;
146
  }
147
}
1481
A src/main/java/com/scrivenvar/processors/markdown/ImageLinkExtension.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors.markdown;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.preferences.UserPreferences;
32
import com.scrivenvar.service.Options;
33
import com.scrivenvar.service.events.Notifier;
34
import com.scrivenvar.util.ProtocolResolver;
35
import com.vladsch.flexmark.ast.Image;
36
import com.vladsch.flexmark.html.HtmlRenderer;
37
import com.vladsch.flexmark.html.IndependentLinkResolverFactory;
38
import com.vladsch.flexmark.html.LinkResolver;
39
import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext;
40
import com.vladsch.flexmark.html.renderer.LinkStatus;
41
import com.vladsch.flexmark.html.renderer.ResolvedLink;
42
import com.vladsch.flexmark.util.ast.Node;
43
import com.vladsch.flexmark.util.data.MutableDataHolder;
44
import org.jetbrains.annotations.NotNull;
45
import org.renjin.repackaged.guava.base.Splitter;
46
47
import java.io.File;
48
import java.nio.file.Path;
49
50
import static java.lang.String.format;
51
52
/**
53
 * Responsible for ensuring that images can be rendered relative to a path.
54
 * This allows images to be located virtually anywhere.
55
 *
56
 * @author White Magic Software, Ltd.
57
 */
58
public class ImageLinkExtension implements HtmlRenderer.HtmlRendererExtension {
59
  private final static Options sOptions = Services.load( Options.class );
60
  private final static Notifier sNotifier = Services.load( Notifier.class );
61
62
  /**
63
   * Creates an extension capable of using a relative path to embed images.
64
   *
65
   * @param path The {@link Path} to the file being edited; the parent path
66
   *             is the starting location of the relative image directory.
67
   * @return The new {@link ImageLinkExtension}, never {@code null}.
68
   */
69
  public static ImageLinkExtension create( final Path path ) {
70
    return new ImageLinkExtension( path );
71
  }
72
73
  private class Factory extends IndependentLinkResolverFactory {
74
    @Override
75
    public @NotNull LinkResolver apply(
76
        @NotNull final LinkResolverBasicContext context ) {
77
      return new ImageLinkResolver();
78
    }
79
  }
80
81
  private class ImageLinkResolver implements LinkResolver {
82
    private final UserPreferences mUserPref = getUserPreferences();
83
    private final String mImagePrefix =
84
        mUserPref.getImagesDirectory().toString();
85
    private final String mImageSuffixes = mUserPref.getImagesOrder();
86
87
    public ImageLinkResolver() {
88
    }
89
90
    // you can also set/clear/modify attributes through
91
    // ResolvedLink.getAttributes() and
92
    // ResolvedLink.getNonNullAttributes()
93
    @NotNull
94
    @Override
95
    public ResolvedLink resolveLink(
96
        @NotNull final Node node,
97
        @NotNull final LinkResolverBasicContext context,
98
        @NotNull final ResolvedLink link ) {
99
      return node instanceof Image ? resolve( link ) : link;
100
    }
101
102
    @NotNull
103
    private ResolvedLink resolve( @NotNull final ResolvedLink link ) {
104
      String url = link.getUrl();
105
106
      try {
107
        final String imageFile = format( "%s/%s", getImagePrefix(), url );
108
        final String suffixes = getImageSuffixes();
109
        final String editDir = getEditDirectory();
110
111
        for( final String ext : Splitter.on( ' ' ).split( suffixes ) ) {
112
          final String imagePath = format(
113
              "%s/%s.%s", editDir, imageFile, ext );
114
          final File file = new File( imagePath );
115
116
          if( file.exists() ) {
117
            url = file.toString();
118
            break;
119
          }
120
        }
121
122
        final String protocol = ProtocolResolver.getProtocol( url );
123
        if( "file".equals( protocol ) ) {
124
          url = "file://" + url;
125
        }
126
127
        return link.withStatus( LinkStatus.VALID ).withUrl( url );
128
      } catch( final Exception e ) {
129
        getNotifier().notify( e );
130
      }
131
132
      return link;
133
    }
134
135
    private String getImagePrefix() {
136
      return mImagePrefix;
137
    }
138
139
    private String getImageSuffixes() {
140
      return mImageSuffixes;
141
    }
142
143
    private String getEditDirectory() {
144
      return mPath.getParent().toString();
145
    }
146
  }
147
148
  private final Path mPath;
149
150
  private ImageLinkExtension( final Path path ) {
151
    mPath = path;
152
  }
153
154
  @Override
155
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
156
  }
157
158
  @Override
159
  public void extend(
160
      final HtmlRenderer.Builder rendererBuilder,
161
      @NotNull final String rendererType ) {
162
    rendererBuilder.linkResolverFactory( new Factory() );
163
  }
164
165
  private UserPreferences getUserPreferences() {
166
    return getOptions().getUserPreferences();
167
  }
168
169
  private Options getOptions() {
170
    return sOptions;
171
  }
172
173
  private Notifier getNotifier() {
174
    return sNotifier;
175
  }
176
}
1177
A src/main/java/com/scrivenvar/processors/markdown/MarkdownProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors.markdown;
29
30
import com.scrivenvar.processors.AbstractProcessor;
31
import com.scrivenvar.processors.Processor;
32
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
33
import com.vladsch.flexmark.ext.superscript.SuperscriptExtension;
34
import com.vladsch.flexmark.ext.tables.TablesExtension;
35
import com.vladsch.flexmark.html.HtmlRenderer;
36
import com.vladsch.flexmark.parser.Parser;
37
import com.vladsch.flexmark.util.ast.IParse;
38
import com.vladsch.flexmark.util.ast.Node;
39
import com.vladsch.flexmark.util.misc.Extension;
40
41
import java.nio.file.Path;
42
import java.util.ArrayList;
43
import java.util.Collection;
44
45
import static com.scrivenvar.Constants.USER_DIRECTORY;
46
47
/**
48
 * Responsible for parsing a Markdown document and rendering it as HTML.
49
 *
50
 * @author White Magic Software, Ltd.
51
 */
52
public class MarkdownProcessor extends AbstractProcessor<String> {
53
54
  private final HtmlRenderer mRenderer;
55
  private final IParse mParser;
56
57
  public MarkdownProcessor(
58
      final Processor<String> successor ) {
59
    this( successor, Path.of( USER_DIRECTORY ) );
60
  }
61
62
  /**
63
   * Constructs a new Markdown processor that can create HTML documents.
64
   *
65
   * @param successor Usually the HTML Preview Processor.
66
   */
67
  public MarkdownProcessor(
68
      final Processor<String> successor, final Path path ) {
69
    super( successor );
70
71
    final Collection<Extension> extensions = new ArrayList<>();
72
    extensions.add( TablesExtension.create() );
73
    extensions.add( SuperscriptExtension.create() );
74
    extensions.add( StrikethroughSubscriptExtension.create() );
75
    extensions.add( ImageLinkExtension.create( path ) );
76
77
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
78
    mParser = Parser.builder().extensions( extensions ).build();
79
  }
80
81
  /**
82
   * Converts the given Markdown string into HTML, without the doctype, html,
83
   * head, and body tags.
84
   *
85
   * @param markdown The string to convert from Markdown to HTML.
86
   * @return The HTML representation of the Markdown document.
87
   */
88
  @Override
89
  public String processLink( final String markdown ) {
90
    return toHtml( markdown );
91
  }
92
93
  /**
94
   * Returns the AST in the form of a node for the given markdown document. This
95
   * can be used, for example, to determine if a hyperlink exists inside of a
96
   * paragraph.
97
   *
98
   * @param markdown The markdown to convert into an AST.
99
   * @return The markdown AST for the given text (usually a paragraph).
100
   */
101
  public Node toNode( final String markdown ) {
102
    return parse( markdown );
103
  }
104
105
  /**
106
   * Helper method to create an AST given some markdown.
107
   *
108
   * @param markdown The markdown to parse.
109
   * @return The root node of the markdown tree.
110
   */
111
  private Node parse( final String markdown ) {
112
    return getParser().parse( markdown );
113
  }
114
115
  /**
116
   * Converts a string of markdown into HTML.
117
   *
118
   * @param markdown The markdown text to convert to HTML, must not be null.
119
   * @return The markdown rendered as an HTML document.
120
   */
121
  private String toHtml( final String markdown ) {
122
    return getRenderer().render( parse( markdown ) );
123
  }
124
125
  /**
126
   * Creates the Markdown document processor.
127
   *
128
   * @return A Parser that can build an abstract syntax tree.
129
   */
130
  private IParse getParser() {
131
    return mParser;
132
  }
133
134
  private HtmlRenderer getRenderer() {
135
    return mRenderer;
136
  }
137
}
1138
M src/main/java/com/scrivenvar/service/Options.java
2828
package com.scrivenvar.service;
2929
30
import com.scrivenvar.preferences.UserPreferences;
31
3032
import java.util.prefs.BackingStoreException;
3133
import java.util.prefs.Preferences;
3234
3335
/**
3436
 * Responsible for persisting options.
3537
 *
3638
 * @author White Magic Software, Ltd.
3739
 */
3840
public interface Options extends Service {
41
42
  /**
43
   * Returns a reference to the persistent settings that may be configured
44
   * through the UI.
45
   *
46
   * @return A valid {@link UserPreferences} instance, never {@code null}.
47
   */
48
  UserPreferences getUserPreferences();
3949
50
  /**
51
   * Returns the {@link Preferences} that persist settings that cannot
52
   * be configured via the user interface.
53
   *
54
   * @return A valid {@link Preferences} instance, never {@code null}.
55
   */
4056
  Preferences getState();
4157
M src/main/java/com/scrivenvar/service/impl/DefaultOptions.java
2727
package com.scrivenvar.service.impl;
2828
29
import static com.scrivenvar.Constants.PREFS_OPTIONS;
30
import static com.scrivenvar.Constants.PREFS_ROOT;
31
import static com.scrivenvar.Constants.PREFS_STATE;
32
29
import com.scrivenvar.preferences.UserPreferences;
3330
import com.scrivenvar.service.Options;
3431
3532
import java.util.prefs.BackingStoreException;
3633
import java.util.prefs.Preferences;
3734
35
import static com.scrivenvar.Constants.PREFS_ROOT;
36
import static com.scrivenvar.Constants.PREFS_STATE;
3837
import static java.util.prefs.Preferences.userRoot;
3938
4039
/**
4140
 * Persistent options user can change at runtime.
4241
 *
4342
 * @author Karl Tauber and White Magic Software, Ltd.
4443
 */
4544
public class DefaultOptions implements Options {
46
47
  private Preferences mPreferences;
45
  private final UserPreferences mPreferences = new UserPreferences();
4846
4947
  public DefaultOptions() {
50
    setPreferences( getRootPreferences().node( PREFS_OPTIONS ) );
5148
  }
5249
...
7471
  public String get( final String key ) {
7572
    return get( key, "" );
76
  }
77
78
  private void setPreferences( final Preferences preferences ) {
79
    mPreferences = preferences;
8073
  }
8174
8275
  private Preferences getRootPreferences() {
8376
    return userRoot().node( PREFS_ROOT );
8477
  }
8578
8679
  @Override
8780
  public Preferences getState() {
8881
    return getRootPreferences().node( PREFS_STATE );
82
  }
83
84
  @Override
85
  public UserPreferences getUserPreferences() {
86
    return mPreferences;
8987
  }
9088
}
M src/main/java/com/scrivenvar/util/Action.java
11
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
2
 * Copyright (c) 2015 Karl Tauber and White Magic Software, Ltd.
33
 * All rights reserved.
44
 *
...
3737
 *
3838
 * @author Karl Tauber
39
 * @author White Magic Software, Ltd.
3940
 */
4041
public class Action {
41
4242
  public final String text;
4343
  public final KeyCombination accelerator;
4444
  public final GlyphIcons icon;
4545
  public final EventHandler<ActionEvent> action;
4646
  public final ObservableBooleanValue disable;
47
48
  public Action(
49
    final String text,
50
    final String accelerator,
51
    final GlyphIcons icon,
52
    final EventHandler<ActionEvent> action ) {
53
    this( text, accelerator, icon, action, null );
54
  }
5547
5648
  public Action(
57
    final String text,
58
    final String accelerator,
59
    final GlyphIcons icon,
60
    final EventHandler<ActionEvent> action,
61
    final ObservableBooleanValue disable ) {
49
      final String text,
50
      final String accelerator,
51
      final GlyphIcons icon,
52
      final EventHandler<ActionEvent> action,
53
      final ObservableBooleanValue disable ) {
6254
6355
    this.text = text;
64
    this.accelerator = (accelerator != null)
65
      ? KeyCombination.valueOf( accelerator )
66
      : null;
56
    this.accelerator = accelerator == null ?
57
        null : KeyCombination.valueOf( accelerator );
6758
    this.icon = icon;
6859
    this.action = action;
A src/main/java/com/scrivenvar/util/ActionBuilder.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.util;
29
30
import com.scrivenvar.Messages;
31
import de.jensd.fx.glyphs.GlyphIcons;
32
import javafx.beans.value.ObservableBooleanValue;
33
import javafx.event.ActionEvent;
34
import javafx.event.EventHandler;
35
36
/**
37
 * Provides a fluent interface around constructing actions so that duplication
38
 * can be avoided.
39
 */
40
public class ActionBuilder {
41
  private String mText;
42
  private String mAccelerator;
43
  private GlyphIcons mIcon;
44
  private EventHandler<ActionEvent> mAction;
45
  private ObservableBooleanValue mDisable;
46
47
  /**
48
   * Sets the action text based on a resource bundle key.
49
   *
50
   * @param key The key to look up in the {@link Messages}.
51
   * @return The corresponding value, or the key name if none found.
52
   */
53
  public ActionBuilder setText( final String key ) {
54
    mText = Messages.get( key, key );
55
    return this;
56
  }
57
58
  public ActionBuilder setAccelerator( final String accelerator ) {
59
    mAccelerator = accelerator;
60
    return this;
61
  }
62
63
  public ActionBuilder setIcon( final GlyphIcons icon ) {
64
    mIcon = icon;
65
    return this;
66
  }
67
68
  public ActionBuilder setAction( final EventHandler<ActionEvent> action ) {
69
    mAction = action;
70
    return this;
71
  }
72
73
  public ActionBuilder setDisable( final ObservableBooleanValue disable ) {
74
    mDisable = disable;
75
    return this;
76
  }
77
78
  public Action build() {
79
    return new Action( mText, mAccelerator, mIcon, mAction, mDisable );
80
  }
81
}
182
M src/main/java/com/scrivenvar/util/ActionUtils.java
11
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
2
 * Copyright (c) 2015 Karl Tauber
33
 * All rights reserved.
44
 *
...
4141
 *
4242
 * @author Karl Tauber
43
 * @author White Magic Software, Ltd.
4344
 */
4445
public class ActionUtils {
4546
4647
  public static Menu createMenu( final String text, final Action... actions ) {
4748
    return new Menu( text, null, createMenuItems( actions ) );
4849
  }
4950
50
  public static MenuItem[] createMenuItems( Action... actions ) {
51
    MenuItem[] menuItems = new MenuItem[ actions.length ];
51
  public static MenuItem[] createMenuItems( final Action... actions ) {
52
    final MenuItem[] menuItems = new MenuItem[ actions.length ];
53
5254
    for( int i = 0; i < actions.length; i++ ) {
5355
      menuItems[ i ] = (actions[ i ] != null)
54
        ? createMenuItem( actions[ i ] )
55
        : new SeparatorMenuItem();
56
          ? createMenuItem( actions[ i ] )
57
          : new SeparatorMenuItem();
5658
    }
59
5760
    return menuItems;
5861
  }
5962
60
  public static MenuItem createMenuItem( Action action ) {
61
    MenuItem menuItem = new MenuItem( action.text );
63
  public static MenuItem createMenuItem( final Action action ) {
64
    final MenuItem menuItem = new MenuItem( action.text );
65
6266
    if( action.accelerator != null ) {
6367
      menuItem.setAccelerator( action.accelerator );
6468
    }
6569
6670
    if( action.icon != null ) {
67
      menuItem.setGraphic( FontAwesomeIconFactory.get().createIcon( action.icon ) );
71
      menuItem.setGraphic(
72
          FontAwesomeIconFactory.get().createIcon( action.icon ) );
6873
    }
6974
...
7984
  }
8085
81
  public static ToolBar createToolBar( Action... actions ) {
86
  public static ToolBar createToolBar( final Action... actions ) {
8287
    return new ToolBar( createToolBarButtons( actions ) );
8388
  }
8489
85
  public static Node[] createToolBarButtons( Action... actions ) {
90
  public static Node[] createToolBarButtons( final Action... actions ) {
8691
    Node[] buttons = new Node[ actions.length ];
8792
    for( int i = 0; i < actions.length; i++ ) {
8893
      buttons[ i ] = (actions[ i ] != null)
89
        ? createToolBarButton( actions[ i ] )
90
        : new Separator();
94
          ? createToolBarButton( actions[ i ] )
95
          : new Separator();
9196
    }
9297
    return buttons;
9398
  }
9499
95
  public static Button createToolBarButton( Action action ) {
96
    Button button = new Button();
97
    button.setGraphic( FontAwesomeIconFactory.get().createIcon( action.icon, "1.2em" ) );
100
  public static Button createToolBarButton( final Action action ) {
101
    final Button button = new Button();
102
    button.setGraphic(
103
        FontAwesomeIconFactory
104
            .get()
105
            .createIcon( action.icon, "1.2em" ) );
106
98107
    String tooltip = action.text;
108
99109
    if( tooltip.endsWith( "..." ) ) {
100110
      tooltip = tooltip.substring( 0, tooltip.length() - 3 );
101111
    }
112
102113
    if( action.accelerator != null ) {
103114
      tooltip += " (" + action.accelerator.getDisplayText() + ')';
104115
    }
116
105117
    button.setTooltip( new Tooltip( tooltip ) );
106118
    button.setFocusTraversable( false );
107119
    button.setOnAction( action.action );
120
108121
    if( action.disable != null ) {
109122
      button.disableProperty().bind( action.disable );
110123
    }
124
111125
    return button;
112126
  }
A src/main/java/com/scrivenvar/util/ProtocolResolver.java
1
package com.scrivenvar.util;
2
3
import java.io.File;
4
import java.net.URI;
5
import java.net.URL;
6
7
import static com.scrivenvar.Constants.DEFINITION_PROTOCOL_UNKNOWN;
8
9
/**
10
 * Responsible for determining the protocol of a resource.
11
 */
12
public class ProtocolResolver {
13
  /**
14
   * Returns the protocol for a given URI or filename.
15
   *
16
   * @param resource Determine the protocol for this URI or filename.
17
   * @return The protocol for the given source.
18
   */
19
  public static String getProtocol( final String resource ) {
20
    String protocol;
21
22
    try {
23
      final URI uri = new URI( resource );
24
25
      if( uri.isAbsolute() ) {
26
        protocol = uri.getScheme();
27
      }
28
      else {
29
        final URL url = new URL( resource );
30
        protocol = url.getProtocol();
31
      }
32
    } catch( final Exception e ) {
33
      // Could be HTTP, HTTPS?
34
      if( resource.startsWith( "//" ) ) {
35
        throw new IllegalArgumentException( "Relative context: " + resource );
36
      }
37
      else {
38
        final File file = new File( resource );
39
        protocol = getProtocol( file );
40
      }
41
    }
42
43
    return protocol;
44
  }
45
46
  /**
47
   * Returns the protocol for a given file.
48
   *
49
   * @param file Determine the protocol for this file.
50
   * @return The protocol for the given file.
51
   */
52
  public static String getProtocol( final File file ) {
53
    String result;
54
55
    try {
56
      result = file.toURI().toURL().getProtocol();
57
    } catch( final Exception e ) {
58
      result = DEFINITION_PROTOCOL_UNKNOWN;
59
    }
60
61
    return result;
62
  }
63
}
164
M src/main/resources/com/scrivenvar/messages.properties
2323
Main.menu.edit.find=_Find
2424
Main.menu.edit.find.next=Find _Next
25
Main.menu.edit.preferences=_Preferences
2526
2627
Main.menu.insert=_Insert
...
3637
Main.menu.insert.link=Link...
3738
Main.menu.insert.image=Image...
38
Main.menu.insert.header_1=Header 1
39
Main.menu.insert.header_1.prompt=header 1
40
Main.menu.insert.header_2=Header 2
41
Main.menu.insert.header_2.prompt=header 2
42
Main.menu.insert.header_3=Header 3
43
Main.menu.insert.header_3.prompt=header 3
39
Main.menu.insert.header.1=Header 1
40
Main.menu.insert.header.1.prompt=header 1
41
Main.menu.insert.header.2=Header 2
42
Main.menu.insert.header.2.prompt=header 2
43
Main.menu.insert.header.3=Header 3
44
Main.menu.insert.header.3.prompt=header 3
4445
Main.menu.insert.unordered_list=Unordered List
4546
Main.menu.insert.ordered_list=Ordered List
4647
Main.menu.insert.horizontal_rule=Horizontal Rule
47
Main.menu.r=_R
48
Main.menu.r.script=_Script
49
Main.menu.r.directory=_Directory
5048
5149
Main.menu.help=_Help
...
6058
Main.statusbar.state.default=OK
6159
Main.statusbar.parse.error={0} (near ${Main.statusbar.text.offset} {1})
60
61
# ########################################################################
62
# Preferences
63
# ########################################################################
64
65
Preferences.r=R
66
Preferences.r.script=Startup Script
67
Preferences.r.script.desc=Script runs prior to executing R statements within the document.
68
Preferences.r.directory=Working Directory
69
Preferences.r.directory.desc=Value assigned to $application.r.working.directory$ and usable in the startup script.
70
71
Preferences.images=Images
72
Preferences.images.directory=Relative Directory
73
Preferences.images.directory.desc=Path prepended to embedded images referenced using local file paths.
74
Preferences.images.suffixes=Extensions
75
Preferences.images.suffixes.desc=Preferred order of image file types to embed, separated by spaces.
76
77
Preferences.definitions=Definitions
78
Preferences.definitions.path=File name
79
Preferences.definitions.path.desc=Absolute path to interpolated string definitions.
6280
6381
# ########################################################################
...
160178
Dialog.about.header=${Main.title}
161179
Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber
162
163
# R ################################################################
164
165
# ########################################################################
166
# R Script
167
# ########################################################################
168
169
Dialog.r.script.title=R Startup Script
170
Dialog.r.script.content=Provide R statements to run prior to interpreting R statements embedded in the document.
171
172
# ########################################################################
173
# R Directory
174
# ########################################################################
175
176
Dialog.r.directory.title=Bootstrap Working Directory
177
Dialog.r.directory.header=Value for $application.r.working.directory$.
178180
179181
# Options ################################################################
M src/main/resources/com/scrivenvar/preview/webview.css
4343
4444
body {
45
  font-family: Helvetica, arial, freesans, clean, sans-serif;
45
  font-family: serif;
4646
  font-size: 14px;
4747
  line-height: 1.6;
...
312312
img {
313313
  max-width: 100%
314
}
315
316
/* CARET 
317
=============================================================================*/
318
319
#CARETPOSITION {
320
  border-top: 2px solid #333;
321
  border-bottom: 2px solid #333;
322
  border-right: 1px solid #333;
323
  margin-right:-1px;
324
  animation: blink 1s linear infinite;
325
}
326
327
@keyframes blink {
328
  from {
329
    visibility:hidden;
330
  }
331
  50% {
332
    visibility:hidden;
333
  }
334
  to {
335
    visibility:visible;
336
  }
337314
}
338315
M src/main/resources/com/scrivenvar/settings.properties
11
# ########################################################################
2
#
32
# Application
4
#
53
# ########################################################################
64
...
1412
1513
# ########################################################################
16
#
1714
# Preferences
18
#
1915
# ########################################################################
2016
2117
preferences.root=com.${application.title}
2218
preferences.root.state=state
2319
preferences.root.options=options
2420
preferences.root.definition.source=definition.source
2521
2622
# ########################################################################
27
#
2823
# File and Path References
29
#
3024
# ########################################################################
31
3225
file.stylesheet.scene=${application.package}/scene.css
3326
file.stylesheet.markdown=${application.package}/editor/markdown.css
...
4033
file.logo.256=${application.package}/logo256.png
4134
file.logo.512=${application.package}/logo512.png
42
43
# Startup script for R
44
file.r.startup=/${application.package}/startup.R
4535
46
# Default filename when a new file is created.
36
# Default file name when a new file is created.
4737
# This ensures that the file type can always be
4838
# discerned so that the correct type of variable
4939
# reference can be inserted.
5040
file.default=untitled.md
5141
file.definition.default=variables.yaml
52
53
# ########################################################################
54
#
55
# Caret token
56
#
57
# ########################################################################
58
caret.token.base=CARETPOSITION
59
caret.token.markdown=%${constant.caret.token.base}%
60
caret.token.html=<span id="${caret.token.base}"></span>
6142
6243
# ########################################################################
63
#
64
# Filename Extensions
65
#
44
# File name Extensions
6645
# ########################################################################
6746
68
# Comma-separated list of definition filename extensions.
47
# Comma-separated list of definition file name extensions.
6948
definition.file.ext.json=*.json
7049
definition.file.ext.toml=*.toml
7150
definition.file.ext.yaml=*.yml,*.yaml
7251
definition.file.ext.properties=*.properties,*.props
7352
74
# Comma-separated list of filename extensions.
53
# Comma-separated list of file name extensions.
7554
file.ext.rmarkdown=*.Rmd
7655
file.ext.rxml=*.Rxml
7756
file.ext.source=*.md,*.markdown,*.mkdown,*.mdown,*.mkdn,*.mkd,*.mdwn,*.mdtxt,*.mdtext,*.text,*.txt,${file.ext.rmarkdown},${file.ext.rxml}
7857
file.ext.definition=${definition.file.ext.yaml}
7958
file.ext.xml=*.xml,${file.ext.rxml}
8059
file.ext.all=*.*
60
61
# File name extension search order for images.
62
file.ext.image.order=svg pdf png jpg tiff
8163
8264
# ########################################################################
83
#
8465
# Variable Name Editor
85
#
8666
# ########################################################################
8767
8868
# Maximum number of characters for a variable name. A variable is defined
8969
# as one or more non-whitespace characters up to this maximum length.
9070
editor.variable.maxLength=256
9171
9272
# ########################################################################
93
#
9473
# Dialog Preferences
95
#
9674
# ########################################################################
9775
98
# docs.oracle.com/javase/8/javafx/api/javafx/scene/control/ButtonBar.html
9976
dialog.alert.button.order.mac=L_HE+U+FBIX_NCYOA_R
10077
dialog.alert.button.order.linux=L_HE+UNYACBXIO_R
10178
dialog.alert.button.order.windows=L_E+U+FBXI_YNOCAH_R
10279
10380
# Ensures a consistent button order for alert dialogs across platforms (because
104
# the default button order on Linux defies all logic). Power to the people.
81
# the default button order on Linux defies all logic).
10582
dialog.alert.button.order=${dialog.alert.button.order.windows}
10683