Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
11
plugins {
22
  id 'application'
3
  id 'org.openjfx.javafxplugin' version '0.0.8'
3
  id 'org.openjfx.javafxplugin' version '0.0.9'
44
  id 'com.palantir.git-version' version '0.12.3'
55
  id 'org.beryx.jlink' version '2.16.2'
...
2828
    os = [targetOs]
2929
  }
30
}
31
32
javafx {
33
  version = "14"
34
  modules = ['javafx.controls', 'javafx.swing']
35
  configuration = 'compileOnly'
3036
}
3137
...
103109
  fx.each { fxitem ->
104110
    os.each { ositem ->
111
      println "org.openjfx:javafx-${fxitem}:${javafx.version}:${ositem}"
112
105113
      runtimeOnly "org.openjfx:javafx-${fxitem}:${javafx.version}:${ositem}"
106114
    }
107115
  }
108116
109117
  testImplementation('org.junit.jupiter:junit-jupiter-api:5.4.2')
110118
  testRuntime('org.junit.jupiter:junit-jupiter-engine:5.4.2')
111
}
112
113
javafx {
114
  version = "14"
115
  modules = ['javafx.controls', 'javafx.swing']
116119
}
117120
...
171174
  useJUnitPlatform()
172175
}
173
174176
M docs/variables.yaml
1
---
12
formula:
23
  sqrt:
3
    value: 603
4
4
    value: "603"
55
M installer
3434
ARGUMENTS+=(
3535
  "a,arch,Target operating system architecture (amd64)"
36
  "b,build,Suppress building application"
3736
  "o,os,Target operating system (linux, windows, mac)"
3837
  "u,update,Java update version number (${ARG_JAVA_UPDATE})"
...
8281
# ---------------------------------------------------------------------------
8382
utile_build() {
83
  $log "Delete ${ARG_PATH_DIST_JAR}"
84
  rm -f "${ARG_PATH_DIST_JAR}"
85
8486
  $log "Build application for ${ARG_JAVA_OS}"
8587
  gradle clean jar -PtargetOs="${ARG_JAVA_OS}"
...
173175
# ---------------------------------------------------------------------------
174176
utile_create_launcher() {
175
  $log "Create ${APP_NAME}.${APP_EXTENSION}"
177
  local -r FILE_APP_NAME="${APP_NAME}.${APP_EXTENSION}"
178
  $log "Create ${FILE_APP_NAME}"
179
180
  # Warp-packer does not seem to overwrite the file.
181
  rm -f "${FILE_APP_NAME}"
176182
177183
  # Download uses amd64, but warp-packer differs.
...
184190
    --input_dir "${ARG_DIR_DIST}" \
185191
    --exec "${FILE_DIST_EXEC}" \
186
    --output "${APP_NAME}.${APP_EXTENSION}" > /dev/null
192
    --output "${FILE_APP_NAME}" > /dev/null
187193
188
  chmod +x "${APP_NAME}.${APP_EXTENSION}"
194
  chmod +x "${FILE_APP_NAME}"
189195
}
190196
191197
argument() {
192198
  local consume=2
193199
194200
  case "$1" in
195201
    -a|--arch)
196202
    ARG_JAVA_ARCH="$2"
197
    ;;
198
    -b|--build)
199
    do_build=noop
200
    consume=1
201203
    ;;
202204
    -o|--os)
M libs/jmathtex/jmathtex.jar
Binary file
A logging/Main.java
1
package com.github.javaparser;
2
3
import com.github.javaparser.ast.CompilationUnit;
4
import com.github.javaparser.ast.body.MethodDeclaration;
5
import com.github.javaparser.ast.body.TypeDeclaration;
6
import com.github.javaparser.ast.stmt.BlockStmt;
7
import com.github.javaparser.ast.stmt.Statement;
8
9
import java.io.File;
10
import java.io.FileNotFoundException;
11
import java.util.List;
12
import java.util.Optional;
13
14
import static com.github.javaparser.StaticJavaParser.parseStatement;
15
import static java.lang.String.format;
16
17
public class Main {
18
  public static void main( final String[] args ) throws FileNotFoundException {
19
    final File sourceFile = new File( args[ 0 ] );
20
    final JavaParser parser = new JavaParser();
21
    final ParseResult<CompilationUnit> pr = parser.parse( sourceFile );
22
    final Optional<CompilationUnit> ocu = pr.getResult();
23
24
    if( ocu.isPresent() ) {
25
      final CompilationUnit cu = ocu.get();
26
      final List<TypeDeclaration<?>> types = cu.getTypes();
27
28
      for( final TypeDeclaration<?> type : types ) {
29
        final List<MethodDeclaration> methods = type.getMethods();
30
31
        for( final MethodDeclaration method : methods ) {
32
          final Optional<BlockStmt> body = method.getBody();
33
          final String m = format( "%s::%s( %s )",
34
                                   type.getNameAsString(),
35
                                   method.getNameAsString(),
36
                                   method.getParameters().toString() );
37
38
          final String mBegan = format(
39
              "System.out.println(\"BEGAN %s\");", m );
40
          final String mEnded = format(
41
              "System.out.println(\"ENDED %s\");", m );
42
43
          final Statement sBegan = parseStatement( mBegan );
44
          final Statement sEnded = parseStatement( mEnded );
45
46
          body.ifPresent( ( b ) -> {
47
            final int i = b.getStatements().size();
48
49
            b.addStatement( 0, sBegan );
50
51
            // Insert before any "return" statement.
52
            b.addStatement( i, sEnded );
53
          } );
54
        }
55
56
        System.out.println( cu.toString() );
57
      }
58
    }
59
  }
60
}
161
A logging/README.md
1
# Logging
2
3
The files in this directory can be used to log the entry/exit to every
4
method for debugging purposes. These changes are not meant to be pushed
5
onto the mainline branch (i.e., not for production use).
6
7
The instructions are relative to the directory containing these instructions.
8
9
# Build
10
11
If modifications to the existing JAR are needed, rebuild the changes
12
as follows:
13
14
    git clone https://github.com/javaparser/javaparser
15
    cd javaparser
16
    cp Main.java ./javaparser-core/src/main/java/com/github/javaparser/.
17
    mvn package -Dmaven.test.skip=true
18
    cp javaparser-core/target/javaparser-core-3.16.2-SNAPSHOT.jar jp.jar
19
20
The file `jp.jar` is built with `Main.class`.
21
22
# Usage
23
24
Run the `inject` script to replace the original files with the logging
25
versions.
26
27
# Revert
28
29
When finished building a debug version of the application, reset the repo
30
as follows:
31
32
    git reset --hard HEAD
33
134
A logging/inject
1
#!/usr/bin/env bash
2
3
echo "Parsing"
4
find ../src/main/java -type f -name "*.java" -exec \
5
  sh -c 'echo {}; java -cp jp.jar com.github.javaparser.Main {} > {}.jp' \;
6
7
echo "Renaming"
8
# The +10c ensures that files without code are skipped.
9
find ../src/main/java -type f -name "*.jp" -size +10c -exec \
10
  sh -c 'echo {}; mv {} $(dirname {})/$(basename {} .jp)' \;
11
112
A logging/jp.jar
Binary file
M src/main/java/com/scrivenvar/Constants.java
170170
   * Default text editor font size, in points.
171171
   */
172
  public static final int FONT_SIZE_EDITOR = 12;
172
  public static final float FONT_SIZE_EDITOR = 12f;
173173
}
174174
M src/main/java/com/scrivenvar/FileEditorTabPane.java
4141
import javafx.collections.ObservableList;
4242
import javafx.event.Event;
43
import javafx.scene.Node;
44
import javafx.scene.control.Alert;
45
import javafx.scene.control.ButtonType;
46
import javafx.scene.control.Tab;
47
import javafx.scene.control.TabPane;
48
import javafx.stage.FileChooser;
49
import javafx.stage.FileChooser.ExtensionFilter;
50
import javafx.stage.Window;
51
52
import java.io.File;
53
import java.nio.file.Path;
54
import java.util.ArrayList;
55
import java.util.List;
56
import java.util.Optional;
57
import java.util.concurrent.atomic.AtomicReference;
58
import java.util.prefs.Preferences;
59
import java.util.stream.Collectors;
60
61
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
62
import static com.scrivenvar.Constants.SETTINGS;
63
import static com.scrivenvar.FileType.*;
64
import static com.scrivenvar.Messages.get;
65
import static com.scrivenvar.predicates.PredicateFactory.createFileTypePredicate;
66
import static com.scrivenvar.service.events.Notifier.YES;
67
68
/**
69
 * Tab pane for file editors.
70
 */
71
public final class FileEditorTabPane extends TabPane {
72
73
  private static final String FILTER_EXTENSION_TITLES =
74
      "Dialog.file.choose.filter";
75
76
  private static final Options sOptions = Services.load( Options.class );
77
  private static final Notifier sNotifier = Services.load( Notifier.class );
78
79
  private final ReadOnlyObjectWrapper<Path> mOpenDefinition =
80
      new ReadOnlyObjectWrapper<>();
81
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
82
      new ReadOnlyObjectWrapper<>();
83
  private final ReadOnlyBooleanWrapper mAnyFileEditorModified =
84
      new ReadOnlyBooleanWrapper();
85
  private final ChangeListener<Integer> mCaretPositionListener;
86
  private final ChangeListener<Integer> mCaretParagraphListener;
87
88
  /**
89
   * Constructs a new file editor tab pane.
90
   *
91
   * @param caretPositionListener  Listens for changes to caret position so
92
   *                               that the status bar can update.
93
   * @param caretParagraphListener Listens for changes to the caret's paragraph
94
   *                               so that scrolling may occur.
95
   */
96
  public FileEditorTabPane(
97
      final ChangeListener<Integer> caretPositionListener,
98
      final ChangeListener<Integer> caretParagraphListener ) {
99
    final ObservableList<Tab> tabs = getTabs();
100
101
    setFocusTraversable( false );
102
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
103
104
    addTabSelectionListener(
105
        ( tabPane, oldTab, newTab ) -> {
106
          if( newTab != null ) {
107
            mActiveFileEditor.set( (FileEditorTab) newTab );
108
          }
109
        }
110
    );
111
112
    final ChangeListener<Boolean> modifiedListener =
113
        ( observable, oldValue, newValue ) -> {
114
          for( final Tab tab : tabs ) {
115
            if( ((FileEditorTab) tab).isModified() ) {
116
              mAnyFileEditorModified.set( true );
117
              break;
118
            }
119
          }
120
        };
121
122
    tabs.addListener(
123
        (ListChangeListener<Tab>) change -> {
124
          while( change.next() ) {
125
            if( change.wasAdded() ) {
126
              change.getAddedSubList().forEach(
127
                  ( tab ) -> {
128
                    final var fet = (FileEditorTab) tab;
129
                    fet.modifiedProperty().addListener( modifiedListener );
130
                  } );
131
            }
132
            else if( change.wasRemoved() ) {
133
              change.getRemoved().forEach(
134
                  ( tab ) -> {
135
                    final var fet = (FileEditorTab) tab;
136
                    fet.modifiedProperty().removeListener( modifiedListener );
137
                  }
138
              );
139
            }
140
          }
141
142
          // Changes in the tabs may also change anyFileEditorModified property
143
          // (e.g. closed modified file)
144
          modifiedListener.changed( null, null, null );
145
        }
146
    );
147
148
    mCaretPositionListener = caretPositionListener;
149
    mCaretParagraphListener = caretParagraphListener;
150
  }
151
152
  /**
153
   * Allows observers to be notified when the current file editor tab changes.
154
   *
155
   * @param listener The listener to notify of tab change events.
156
   */
157
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
158
    // Observe the tab so that when a new tab is opened or selected,
159
    // a notification is kicked off.
160
    getSelectionModel().selectedItemProperty().addListener( listener );
161
  }
162
163
  /**
164
   * Returns the tab that has keyboard focus.
165
   *
166
   * @return A non-null instance.
167
   */
168
  public FileEditorTab getActiveFileEditor() {
169
    return mActiveFileEditor.get();
170
  }
171
172
  /**
173
   * Returns the property corresponding to the tab that has focus.
174
   *
175
   * @return A non-null instance.
176
   */
177
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
178
    return mActiveFileEditor.getReadOnlyProperty();
179
  }
180
181
  /**
182
   * Property that can answer whether the text has been modified.
183
   *
184
   * @return A non-null instance, true meaning the content has not been saved.
185
   */
186
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
187
    return mAnyFileEditorModified.getReadOnlyProperty();
188
  }
189
190
  /**
191
   * Creates a new editor instance from the given path.
192
   *
193
   * @param path The file to open.
194
   * @return A non-null instance.
195
   */
196
  private FileEditorTab createFileEditor( final Path path ) {
197
    assert path != null;
198
199
    final FileEditorTab tab = new FileEditorTab( path );
200
201
    tab.setOnCloseRequest( e -> {
202
      if( !canCloseEditor( tab ) ) {
203
        e.consume();
204
      }
205
      else if( isActiveFileEditor( tab ) ) {
206
        // Prevent prompting the user to save when there are no file editor
207
        // tabs open.
208
        mActiveFileEditor.set( null );
209
      }
210
    } );
211
212
    tab.addCaretPositionListener( mCaretPositionListener );
213
    tab.addCaretParagraphListener( mCaretParagraphListener );
214
215
    return tab;
216
  }
217
218
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
219
    return getActiveFileEditor() == tab;
220
  }
221
222
  private Path getDefaultPath() {
223
    final String filename = getDefaultFilename();
224
    return (new File( filename )).toPath();
225
  }
226
227
  private String getDefaultFilename() {
228
    return getSettings().getSetting( "file.default", "untitled.md" );
229
  }
230
231
  /**
232
   * Called to add a new {@link FileEditorTab} to the tab pane.
233
   */
234
  void newEditor() {
235
    final FileEditorTab tab = createFileEditor( getDefaultPath() );
236
237
    getTabs().add( tab );
238
    getSelectionModel().select( tab );
239
  }
240
241
  void openFileDialog() {
242
    final String title = get( "Dialog.file.choose.open.title" );
243
    final FileChooser dialog = createFileChooser( title );
244
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
245
246
    if( files != null ) {
247
      openFiles( files );
248
    }
249
  }
250
251
  /**
252
   * Opens the files into new editors, unless one of those files was a
253
   * definition file. The definition file is loaded into the definition pane,
254
   * but only the first one selected (multiple definition files will result in a
255
   * warning).
256
   *
257
   * @param files The list of non-definition files that the were requested to
258
   *              open.
259
   */
260
  private void openFiles( final List<File> files ) {
261
    final List<String> extensions =
262
        createExtensionFilter( DEFINITION ).getExtensions();
263
    final var predicate = createFileTypePredicate( extensions );
264
265
    // The user might have opened multiple definitions files. These will
266
    // be discarded from the text editable files.
267
    final var definitions
268
        = files.stream().filter( predicate ).collect( Collectors.toList() );
269
270
    // Create a modifiable list to remove any definition files that were
271
    // opened.
272
    final var editors = new ArrayList<>( files );
273
274
    if( !editors.isEmpty() ) {
275
      saveLastDirectory( editors.get( 0 ) );
276
    }
277
278
    editors.removeAll( definitions );
279
280
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
281
    if( !editors.isEmpty() ) {
282
      openEditors( editors, 0 );
283
    }
284
285
    if( !definitions.isEmpty() ) {
286
      openDefinition( definitions.get( 0 ) );
287
    }
288
  }
289
290
  private void openEditors( final List<File> files, final int activeIndex ) {
291
    final int fileTally = files.size();
292
    final List<Tab> tabs = getTabs();
293
294
    // Close single unmodified "Untitled" tab.
295
    if( tabs.size() == 1 ) {
296
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
297
298
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
299
        closeEditor( fileEditor, false );
300
      }
301
    }
302
303
    for( int i = 0; i < fileTally; i++ ) {
304
      final Path path = files.get( i ).toPath();
305
306
      FileEditorTab fileEditorTab = findEditor( path );
307
308
      // Only open new files.
309
      if( fileEditorTab == null ) {
310
        fileEditorTab = createFileEditor( path );
311
        getTabs().add( fileEditorTab );
312
      }
313
314
      // Select the first file in the list.
315
      if( i == activeIndex ) {
316
        getSelectionModel().select( fileEditorTab );
317
      }
318
    }
319
  }
320
321
  /**
322
   * Returns a property that changes when a new definition file is opened.
323
   *
324
   * @return The path to a definition file that was opened.
325
   */
326
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
327
    return getOnOpenDefinitionFile().getReadOnlyProperty();
328
  }
329
330
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
331
    return mOpenDefinition;
332
  }
333
334
  /**
335
   * Called when the user has opened a definition file (using the file open
336
   * dialog box). This will replace the current set of definitions for the
337
   * active tab.
338
   *
339
   * @param definition The file to open.
340
   */
341
  private void openDefinition( final File definition ) {
342
    // TODO: Prevent reading this file twice when a new text document is opened.
343
    // (might be a matter of checking the value first).
344
    getOnOpenDefinitionFile().set( definition.toPath() );
345
  }
346
347
  /**
348
   * Called when the contents of the editor are to be saved.
349
   *
350
   * @param tab The tab containing content to save.
351
   * @return true The contents were saved (or needn't be saved).
352
   */
353
  public boolean saveEditor( final FileEditorTab tab ) {
354
    if( tab == null || !tab.isModified() ) {
355
      return true;
356
    }
357
358
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
359
  }
360
361
  /**
362
   * Opens the Save As dialog for the user to save the content under a new
363
   * path.
364
   *
365
   * @param tab The tab with contents to save.
366
   * @return true The contents were saved, or the tab was null.
367
   */
368
  public boolean saveEditorAs( final FileEditorTab tab ) {
369
    if( tab == null ) {
370
      return true;
371
    }
372
373
    getSelectionModel().select( tab );
374
375
    final FileChooser fileChooser = createFileChooser( get(
376
        "Dialog.file.choose.save.title" ) );
377
    final File file = fileChooser.showSaveDialog( getWindow() );
378
    if( file == null ) {
379
      return false;
380
    }
381
382
    saveLastDirectory( file );
383
    tab.setPath( file.toPath() );
384
385
    return tab.save();
386
  }
387
388
  void saveAllEditors() {
389
    for( final FileEditorTab fileEditor : getAllEditors() ) {
390
      saveEditor( fileEditor );
391
    }
392
  }
393
394
  /**
395
   * Answers whether the file has had modifications. '
396
   *
397
   * @param tab THe tab to check for modifications.
398
   * @return false The file is unmodified.
399
   */
400
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
401
  boolean canCloseEditor( final FileEditorTab tab ) {
402
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
403
    canClose.set( true );
404
405
    if( tab.isModified() ) {
406
      final Notification message = getNotifyService().createNotification(
407
          Messages.get( "Alert.file.close.title" ),
408
          Messages.get( "Alert.file.close.text" ),
409
          tab.getText()
410
      );
411
412
      final Alert confirmSave = getNotifyService().createConfirmation(
413
          getWindow(), message );
414
415
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
416
417
      buttonType.ifPresent(
418
          save -> canClose.set(
419
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
420
          )
421
      );
422
    }
423
424
    return canClose.get();
425
  }
426
427
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
428
    if( tab == null ) {
429
      return true;
430
    }
431
432
    if( save ) {
433
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
434
      Event.fireEvent( tab, event );
435
436
      if( event.isConsumed() ) {
437
        return false;
438
      }
439
    }
440
441
    getTabs().remove( tab );
442
443
    if( tab.getOnClosed() != null ) {
444
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
445
    }
446
447
    return true;
448
  }
449
450
  boolean closeAllEditors() {
451
    final FileEditorTab[] allEditors = getAllEditors();
452
    final FileEditorTab activeEditor = getActiveFileEditor();
453
454
    // try to save active tab first because in case the user decides to cancel,
455
    // then it stays active
456
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
457
      return false;
458
    }
459
460
    // This should be called any time a tab changes.
461
    persistPreferences();
462
463
    // save modified tabs
464
    for( int i = 0; i < allEditors.length; i++ ) {
465
      final FileEditorTab fileEditor = allEditors[ i ];
466
467
      if( fileEditor == activeEditor ) {
468
        continue;
469
      }
470
471
      if( fileEditor.isModified() ) {
472
        // activate the modified tab to make its modified content visible to
473
        // the user
474
        getSelectionModel().select( i );
475
476
        if( !canCloseEditor( fileEditor ) ) {
477
          return false;
478
        }
479
      }
480
    }
481
482
    // Close all tabs.
483
    for( final FileEditorTab fileEditor : allEditors ) {
484
      if( !closeEditor( fileEditor, false ) ) {
485
        return false;
486
      }
487
    }
488
489
    return getTabs().isEmpty();
490
  }
491
492
  private FileEditorTab[] getAllEditors() {
493
    final ObservableList<Tab> tabs = getTabs();
494
    final int length = tabs.size();
495
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
496
497
    for( int i = 0; i < length; i++ ) {
498
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
499
    }
500
501
    return allEditors;
502
  }
503
504
  /**
505
   * Returns the file editor tab that has the given path.
506
   *
507
   * @return null No file editor tab for the given path was found.
508
   */
509
  private FileEditorTab findEditor( final Path path ) {
510
    for( final Tab tab : getTabs() ) {
511
      final FileEditorTab fileEditor = (FileEditorTab) tab;
512
513
      if( fileEditor.isPath( path ) ) {
514
        return fileEditor;
515
      }
516
    }
517
518
    return null;
519
  }
520
521
  private FileChooser createFileChooser( String title ) {
522
    final FileChooser fileChooser = new FileChooser();
523
524
    fileChooser.setTitle( title );
525
    fileChooser.getExtensionFilters().addAll(
526
        createExtensionFilters() );
527
528
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
529
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
530
531
    if( !file.isDirectory() ) {
532
      file = new File( "." );
533
    }
534
535
    fileChooser.setInitialDirectory( file );
536
    return fileChooser;
537
  }
538
539
  private List<ExtensionFilter> createExtensionFilters() {
540
    final List<ExtensionFilter> list = new ArrayList<>();
541
542
    // TODO: Return a list of all properties that match the filter prefix.
543
    // This will allow dynamic filters to be added and removed just by
544
    // updating the properties file.
545
    list.add( createExtensionFilter( ALL ) );
546
    list.add( createExtensionFilter( SOURCE ) );
547
    list.add( createExtensionFilter( DEFINITION ) );
548
    list.add( createExtensionFilter( XML ) );
549
    return list;
550
  }
551
552
  /**
553
   * Returns a filter for file name extensions recognized by the application
554
   * that can be opened by the user.
555
   *
556
   * @param filetype Used to find the globbing pattern for extensions.
557
   * @return A filename filter suitable for use by a FileDialog instance.
558
   */
559
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
560
    final String tKey = String.format( "%s.title.%s",
561
                                       FILTER_EXTENSION_TITLES,
562
                                       filetype );
563
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
564
565
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
566
  }
567
568
  private void saveLastDirectory( final File file ) {
569
    getPreferences().put( "lastDirectory", file.getParent() );
570
  }
571
572
  public void initPreferences() {
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 List<String> getExtensions( final String key ) {
631
    return getSettings().getStringSettingList( key );
632
  }
633
634
  private Notifier getNotifyService() {
635
    return sNotifier;
636
  }
637
638
  private Settings getSettings() {
639
    return SETTINGS;
640
  }
641
642
  protected Options getOptions() {
643
    return sOptions;
644
  }
645
646
  private Window getWindow() {
647
    return getScene().getWindow();
648
  }
649
650
  private Preferences getPreferences() {
651
    return getOptions().getState();
652
  }
653
654
  Node getNode() {
655
    return this;
43
import javafx.scene.control.Alert;
44
import javafx.scene.control.ButtonType;
45
import javafx.scene.control.Tab;
46
import javafx.scene.control.TabPane;
47
import javafx.stage.FileChooser;
48
import javafx.stage.FileChooser.ExtensionFilter;
49
import javafx.stage.Window;
50
51
import java.io.File;
52
import java.nio.file.Path;
53
import java.util.ArrayList;
54
import java.util.List;
55
import java.util.Optional;
56
import java.util.concurrent.atomic.AtomicReference;
57
import java.util.prefs.Preferences;
58
import java.util.stream.Collectors;
59
60
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
61
import static com.scrivenvar.Constants.SETTINGS;
62
import static com.scrivenvar.FileType.*;
63
import static com.scrivenvar.Messages.get;
64
import static com.scrivenvar.predicates.PredicateFactory.createFileTypePredicate;
65
import static com.scrivenvar.service.events.Notifier.YES;
66
67
/**
68
 * Tab pane for file editors.
69
 */
70
public final class FileEditorTabPane extends TabPane {
71
72
  private static final String FILTER_EXTENSION_TITLES =
73
      "Dialog.file.choose.filter";
74
75
  private static final Options sOptions = Services.load( Options.class );
76
  private static final Notifier sNotifier = Services.load( Notifier.class );
77
78
  private final ReadOnlyObjectWrapper<Path> mOpenDefinition =
79
      new ReadOnlyObjectWrapper<>();
80
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
81
      new ReadOnlyObjectWrapper<>();
82
  private final ReadOnlyBooleanWrapper mAnyFileEditorModified =
83
      new ReadOnlyBooleanWrapper();
84
  private final ChangeListener<Integer> mCaretPositionListener;
85
  private final ChangeListener<Integer> mCaretParagraphListener;
86
87
  /**
88
   * Constructs a new file editor tab pane.
89
   *
90
   * @param caretPositionListener  Listens for changes to caret position so
91
   *                               that the status bar can update.
92
   * @param caretParagraphListener Listens for changes to the caret's paragraph
93
   *                               so that scrolling may occur.
94
   */
95
  public FileEditorTabPane(
96
      final ChangeListener<Integer> caretPositionListener,
97
      final ChangeListener<Integer> caretParagraphListener ) {
98
    final ObservableList<Tab> tabs = getTabs();
99
100
    setFocusTraversable( false );
101
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
102
103
    addTabSelectionListener(
104
        ( tabPane, oldTab, newTab ) -> {
105
          if( newTab != null ) {
106
            mActiveFileEditor.set( (FileEditorTab) newTab );
107
          }
108
        }
109
    );
110
111
    final ChangeListener<Boolean> modifiedListener =
112
        ( observable, oldValue, newValue ) -> {
113
          for( final Tab tab : tabs ) {
114
            if( ((FileEditorTab) tab).isModified() ) {
115
              mAnyFileEditorModified.set( true );
116
              break;
117
            }
118
          }
119
        };
120
121
    tabs.addListener(
122
        (ListChangeListener<Tab>) change -> {
123
          while( change.next() ) {
124
            if( change.wasAdded() ) {
125
              change.getAddedSubList().forEach(
126
                  ( tab ) -> {
127
                    final var fet = (FileEditorTab) tab;
128
                    fet.modifiedProperty().addListener( modifiedListener );
129
                  } );
130
            }
131
            else if( change.wasRemoved() ) {
132
              change.getRemoved().forEach(
133
                  ( tab ) -> {
134
                    final var fet = (FileEditorTab) tab;
135
                    fet.modifiedProperty().removeListener( modifiedListener );
136
                  }
137
              );
138
            }
139
          }
140
141
          // Changes in the tabs may also change anyFileEditorModified property
142
          // (e.g. closed modified file)
143
          modifiedListener.changed( null, null, null );
144
        }
145
    );
146
147
    mCaretPositionListener = caretPositionListener;
148
    mCaretParagraphListener = caretParagraphListener;
149
  }
150
151
  /**
152
   * Allows observers to be notified when the current file editor tab changes.
153
   *
154
   * @param listener The listener to notify of tab change events.
155
   */
156
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
157
    // Observe the tab so that when a new tab is opened or selected,
158
    // a notification is kicked off.
159
    getSelectionModel().selectedItemProperty().addListener( listener );
160
  }
161
162
  /**
163
   * Returns the tab that has keyboard focus.
164
   *
165
   * @return A non-null instance.
166
   */
167
  public FileEditorTab getActiveFileEditor() {
168
    return mActiveFileEditor.get();
169
  }
170
171
  /**
172
   * Returns the property corresponding to the tab that has focus.
173
   *
174
   * @return A non-null instance.
175
   */
176
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
177
    return mActiveFileEditor.getReadOnlyProperty();
178
  }
179
180
  /**
181
   * Property that can answer whether the text has been modified.
182
   *
183
   * @return A non-null instance, true meaning the content has not been saved.
184
   */
185
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
186
    return mAnyFileEditorModified.getReadOnlyProperty();
187
  }
188
189
  /**
190
   * Creates a new editor instance from the given path.
191
   *
192
   * @param path The file to open.
193
   * @return A non-null instance.
194
   */
195
  private FileEditorTab createFileEditor( final Path path ) {
196
    assert path != null;
197
198
    final FileEditorTab tab = new FileEditorTab( path );
199
200
    tab.setOnCloseRequest( e -> {
201
      if( !canCloseEditor( tab ) ) {
202
        e.consume();
203
      }
204
      else if( isActiveFileEditor( tab ) ) {
205
        // Prevent prompting the user to save when there are no file editor
206
        // tabs open.
207
        mActiveFileEditor.set( null );
208
      }
209
    } );
210
211
    tab.addCaretPositionListener( mCaretPositionListener );
212
    tab.addCaretParagraphListener( mCaretParagraphListener );
213
214
    return tab;
215
  }
216
217
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
218
    return getActiveFileEditor() == tab;
219
  }
220
221
  private Path getDefaultPath() {
222
    final String filename = getDefaultFilename();
223
    return (new File( filename )).toPath();
224
  }
225
226
  private String getDefaultFilename() {
227
    return getSettings().getSetting( "file.default", "untitled.md" );
228
  }
229
230
  /**
231
   * Called to add a new {@link FileEditorTab} to the tab pane.
232
   */
233
  void newEditor() {
234
    final FileEditorTab tab = createFileEditor( getDefaultPath() );
235
236
    getTabs().add( tab );
237
    getSelectionModel().select( tab );
238
  }
239
240
  void openFileDialog() {
241
    final String title = get( "Dialog.file.choose.open.title" );
242
    final FileChooser dialog = createFileChooser( title );
243
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
244
245
    if( files != null ) {
246
      openFiles( files );
247
    }
248
  }
249
250
  /**
251
   * Opens the files into new editors, unless one of those files was a
252
   * definition file. The definition file is loaded into the definition pane,
253
   * but only the first one selected (multiple definition files will result in a
254
   * warning).
255
   *
256
   * @param files The list of non-definition files that the were requested to
257
   *              open.
258
   */
259
  private void openFiles( final List<File> files ) {
260
    final List<String> extensions =
261
        createExtensionFilter( DEFINITION ).getExtensions();
262
    final var predicate = createFileTypePredicate( extensions );
263
264
    // The user might have opened multiple definitions files. These will
265
    // be discarded from the text editable files.
266
    final var definitions
267
        = files.stream().filter( predicate ).collect( Collectors.toList() );
268
269
    // Create a modifiable list to remove any definition files that were
270
    // opened.
271
    final var editors = new ArrayList<>( files );
272
273
    if( !editors.isEmpty() ) {
274
      saveLastDirectory( editors.get( 0 ) );
275
    }
276
277
    editors.removeAll( definitions );
278
279
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
280
    if( !editors.isEmpty() ) {
281
      openEditors( editors, 0 );
282
    }
283
284
    if( !definitions.isEmpty() ) {
285
      openDefinition( definitions.get( 0 ) );
286
    }
287
  }
288
289
  private void openEditors( final List<File> files, final int activeIndex ) {
290
    final int fileTally = files.size();
291
    final List<Tab> tabs = getTabs();
292
293
    // Close single unmodified "Untitled" tab.
294
    if( tabs.size() == 1 ) {
295
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
296
297
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
298
        closeEditor( fileEditor, false );
299
      }
300
    }
301
302
    for( int i = 0; i < fileTally; i++ ) {
303
      final Path path = files.get( i ).toPath();
304
305
      FileEditorTab fileEditorTab = findEditor( path );
306
307
      // Only open new files.
308
      if( fileEditorTab == null ) {
309
        fileEditorTab = createFileEditor( path );
310
        getTabs().add( fileEditorTab );
311
      }
312
313
      // Select the first file in the list.
314
      if( i == activeIndex ) {
315
        getSelectionModel().select( fileEditorTab );
316
      }
317
    }
318
  }
319
320
  /**
321
   * Returns a property that changes when a new definition file is opened.
322
   *
323
   * @return The path to a definition file that was opened.
324
   */
325
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
326
    return getOnOpenDefinitionFile().getReadOnlyProperty();
327
  }
328
329
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
330
    return mOpenDefinition;
331
  }
332
333
  /**
334
   * Called when the user has opened a definition file (using the file open
335
   * dialog box). This will replace the current set of definitions for the
336
   * active tab.
337
   *
338
   * @param definition The file to open.
339
   */
340
  private void openDefinition( final File definition ) {
341
    // TODO: Prevent reading this file twice when a new text document is opened.
342
    // (might be a matter of checking the value first).
343
    getOnOpenDefinitionFile().set( definition.toPath() );
344
  }
345
346
  /**
347
   * Called when the contents of the editor are to be saved.
348
   *
349
   * @param tab The tab containing content to save.
350
   * @return true The contents were saved (or needn't be saved).
351
   */
352
  public boolean saveEditor( final FileEditorTab tab ) {
353
    if( tab == null || !tab.isModified() ) {
354
      return true;
355
    }
356
357
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
358
  }
359
360
  /**
361
   * Opens the Save As dialog for the user to save the content under a new
362
   * path.
363
   *
364
   * @param tab The tab with contents to save.
365
   * @return true The contents were saved, or the tab was null.
366
   */
367
  public boolean saveEditorAs( final FileEditorTab tab ) {
368
    if( tab == null ) {
369
      return true;
370
    }
371
372
    getSelectionModel().select( tab );
373
374
    final FileChooser fileChooser = createFileChooser( get(
375
        "Dialog.file.choose.save.title" ) );
376
    final File file = fileChooser.showSaveDialog( getWindow() );
377
    if( file == null ) {
378
      return false;
379
    }
380
381
    saveLastDirectory( file );
382
    tab.setPath( file.toPath() );
383
384
    return tab.save();
385
  }
386
387
  void saveAllEditors() {
388
    for( final FileEditorTab fileEditor : getAllEditors() ) {
389
      saveEditor( fileEditor );
390
    }
391
  }
392
393
  /**
394
   * Answers whether the file has had modifications. '
395
   *
396
   * @param tab THe tab to check for modifications.
397
   * @return false The file is unmodified.
398
   */
399
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
400
  boolean canCloseEditor( final FileEditorTab tab ) {
401
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
402
    canClose.set( true );
403
404
    if( tab.isModified() ) {
405
      final Notification message = getNotifyService().createNotification(
406
          Messages.get( "Alert.file.close.title" ),
407
          Messages.get( "Alert.file.close.text" ),
408
          tab.getText()
409
      );
410
411
      final Alert confirmSave = getNotifyService().createConfirmation(
412
          getWindow(), message );
413
414
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
415
416
      buttonType.ifPresent(
417
          save -> canClose.set(
418
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
419
          )
420
      );
421
    }
422
423
    return canClose.get();
424
  }
425
426
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
427
    if( tab == null ) {
428
      return true;
429
    }
430
431
    if( save ) {
432
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
433
      Event.fireEvent( tab, event );
434
435
      if( event.isConsumed() ) {
436
        return false;
437
      }
438
    }
439
440
    getTabs().remove( tab );
441
442
    if( tab.getOnClosed() != null ) {
443
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
444
    }
445
446
    return true;
447
  }
448
449
  boolean closeAllEditors() {
450
    final FileEditorTab[] allEditors = getAllEditors();
451
    final FileEditorTab activeEditor = getActiveFileEditor();
452
453
    // try to save active tab first because in case the user decides to cancel,
454
    // then it stays active
455
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
456
      return false;
457
    }
458
459
    // This should be called any time a tab changes.
460
    persistPreferences();
461
462
    // save modified tabs
463
    for( int i = 0; i < allEditors.length; i++ ) {
464
      final FileEditorTab fileEditor = allEditors[ i ];
465
466
      if( fileEditor == activeEditor ) {
467
        continue;
468
      }
469
470
      if( fileEditor.isModified() ) {
471
        // activate the modified tab to make its modified content visible to
472
        // the user
473
        getSelectionModel().select( i );
474
475
        if( !canCloseEditor( fileEditor ) ) {
476
          return false;
477
        }
478
      }
479
    }
480
481
    // Close all tabs.
482
    for( final FileEditorTab fileEditor : allEditors ) {
483
      if( !closeEditor( fileEditor, false ) ) {
484
        return false;
485
      }
486
    }
487
488
    return getTabs().isEmpty();
489
  }
490
491
  private FileEditorTab[] getAllEditors() {
492
    final ObservableList<Tab> tabs = getTabs();
493
    final int length = tabs.size();
494
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
495
496
    for( int i = 0; i < length; i++ ) {
497
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
498
    }
499
500
    return allEditors;
501
  }
502
503
  /**
504
   * Returns the file editor tab that has the given path.
505
   *
506
   * @return null No file editor tab for the given path was found.
507
   */
508
  private FileEditorTab findEditor( final Path path ) {
509
    for( final Tab tab : getTabs() ) {
510
      final FileEditorTab fileEditor = (FileEditorTab) tab;
511
512
      if( fileEditor.isPath( path ) ) {
513
        return fileEditor;
514
      }
515
    }
516
517
    return null;
518
  }
519
520
  private FileChooser createFileChooser( String title ) {
521
    final FileChooser fileChooser = new FileChooser();
522
523
    fileChooser.setTitle( title );
524
    fileChooser.getExtensionFilters().addAll(
525
        createExtensionFilters() );
526
527
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
528
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
529
530
    if( !file.isDirectory() ) {
531
      file = new File( "." );
532
    }
533
534
    fileChooser.setInitialDirectory( file );
535
    return fileChooser;
536
  }
537
538
  private List<ExtensionFilter> createExtensionFilters() {
539
    final List<ExtensionFilter> list = new ArrayList<>();
540
541
    // TODO: Return a list of all properties that match the filter prefix.
542
    // This will allow dynamic filters to be added and removed just by
543
    // updating the properties file.
544
    list.add( createExtensionFilter( ALL ) );
545
    list.add( createExtensionFilter( SOURCE ) );
546
    list.add( createExtensionFilter( DEFINITION ) );
547
    list.add( createExtensionFilter( XML ) );
548
    return list;
549
  }
550
551
  /**
552
   * Returns a filter for file name extensions recognized by the application
553
   * that can be opened by the user.
554
   *
555
   * @param filetype Used to find the globbing pattern for extensions.
556
   * @return A filename filter suitable for use by a FileDialog instance.
557
   */
558
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
559
    final String tKey = String.format( "%s.title.%s",
560
                                       FILTER_EXTENSION_TITLES,
561
                                       filetype );
562
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
563
564
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
565
  }
566
567
  private void saveLastDirectory( final File file ) {
568
    getPreferences().put( "lastDirectory", file.getParent() );
569
  }
570
571
  public void initPreferences() {
572
    int activeIndex = 0;
573
574
    final Preferences preferences = getPreferences();
575
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
576
    final String activeFileName = preferences.get( "activeFile", null );
577
578
    final List<File> files = new ArrayList<>( fileNames.length );
579
580
    for( final String fileName : fileNames ) {
581
      final File file = new File( fileName );
582
583
      if( file.exists() ) {
584
        files.add( file );
585
586
        if( fileName.equals( activeFileName ) ) {
587
          activeIndex = files.size() - 1;
588
        }
589
      }
590
    }
591
592
    if( files.isEmpty() ) {
593
      newEditor();
594
    }
595
    else {
596
      openEditors( files, activeIndex );
597
    }
598
  }
599
600
  public void persistPreferences() {
601
    final var allEditors = getTabs();
602
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
603
604
    for( final var tab : allEditors ) {
605
      final var fileEditor = (FileEditorTab) tab;
606
      final var filePath = fileEditor.getPath();
607
608
      if( filePath != null ) {
609
        fileNames.add( filePath.toString() );
610
      }
611
    }
612
613
    final var preferences = getPreferences();
614
    Utils.putPrefsStrings( preferences,
615
                           "file",
616
                           fileNames.toArray( new String[ 0 ] ) );
617
618
    final var activeEditor = getActiveFileEditor();
619
    final var filePath = activeEditor == null ? null : activeEditor.getPath();
620
621
    if( filePath == null ) {
622
      preferences.remove( "activeFile" );
623
    }
624
    else {
625
      preferences.put( "activeFile", filePath.toString() );
626
    }
627
  }
628
629
  private List<String> getExtensions( final String key ) {
630
    return getSettings().getStringSettingList( key );
631
  }
632
633
  private Notifier getNotifyService() {
634
    return sNotifier;
635
  }
636
637
  private Settings getSettings() {
638
    return SETTINGS;
639
  }
640
641
  protected Options getOptions() {
642
    return sOptions;
643
  }
644
645
  private Window getWindow() {
646
    return getScene().getWindow();
647
  }
648
649
  private Preferences getPreferences() {
650
    return getOptions().getState();
656651
  }
657652
}
M src/main/java/com/scrivenvar/MainWindow.java
8888
8989
import java.io.BufferedReader;
90
import java.io.InputStreamReader;
91
import java.nio.file.Path;
92
import java.nio.file.Paths;
93
import java.util.*;
94
import java.util.concurrent.atomic.AtomicInteger;
95
import java.util.function.Consumer;
96
import java.util.function.Function;
97
import java.util.prefs.Preferences;
98
import java.util.stream.Collectors;
99
100
import static com.scrivenvar.Constants.*;
101
import static com.scrivenvar.Messages.get;
102
import static com.scrivenvar.StatusBarNotifier.alert;
103
import static com.scrivenvar.util.StageState.*;
104
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
105
import static java.nio.charset.StandardCharsets.UTF_8;
106
import static java.util.Collections.emptyList;
107
import static java.util.Collections.singleton;
108
import static javafx.application.Platform.runLater;
109
import static javafx.event.Event.fireEvent;
110
import static javafx.scene.input.KeyCode.ENTER;
111
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
112
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
113
114
/**
115
 * Main window containing a tab pane in the center for file editors.
116
 */
117
public class MainWindow implements Observer {
118
  /**
119
   * The {@code OPTIONS} variable must be declared before all other variables
120
   * to prevent subsequent initializations from failing due to missing user
121
   * preferences.
122
   */
123
  private static final Options sOptions = Services.load( Options.class );
124
  private static final Snitch SNITCH = Services.load( Snitch.class );
125
126
  private final Scene mScene;
127
  private final StatusBar mStatusBar;
128
  private final Text mLineNumberText;
129
  private final TextField mFindTextField;
130
  private final SpellChecker mSpellChecker;
131
132
  private final Object mMutex = new Object();
133
134
  /**
135
   * Prevents re-instantiation of processing classes.
136
   */
137
  private final Map<FileEditorTab, Processor<String>> mProcessors =
138
      new HashMap<>();
139
140
  private final Map<String, String> mResolvedMap =
141
      new HashMap<>( DEFAULT_MAP_SIZE );
142
143
  private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
144
      event -> rerender();
145
146
  /**
147
   * Called when the definition data is changed.
148
   */
149
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
150
      mTreeHandler = event -> {
151
    exportDefinitions( getDefinitionPath() );
152
    interpolateResolvedMap();
153
    rerender();
154
  };
155
156
  /**
157
   * Called to inject the selected item when the user presses ENTER in the
158
   * definition pane.
159
   */
160
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
161
      event -> {
162
        if( event.getCode() == ENTER ) {
163
          getVariableNameInjector().injectSelectedItem();
164
        }
165
      };
166
167
  private final ChangeListener<Integer> mCaretPositionListener =
168
      ( observable, oldPosition, newPosition ) -> {
169
        final FileEditorTab tab = getActiveFileEditorTab();
170
        final EditorPane pane = tab.getEditorPane();
171
        final StyleClassedTextArea editor = pane.getEditor();
172
173
        getLineNumberText().setText(
174
            get( STATUS_BAR_LINE,
175
                 editor.getCurrentParagraph() + 1,
176
                 editor.getParagraphs().size(),
177
                 editor.getCaretPosition()
178
            )
179
        );
180
      };
181
182
  private final ChangeListener<Integer> mCaretParagraphListener =
183
      ( observable, oldIndex, newIndex ) ->
184
          scrollToParagraph( newIndex, true );
185
186
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
187
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
188
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
189
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
190
      mCaretPositionListener,
191
      mCaretParagraphListener );
192
193
  /**
194
   * Listens on the definition pane for double-click events.
195
   */
196
  private final DefinitionNameInjector mVariableNameInjector
197
      = new DefinitionNameInjector( mDefinitionPane );
198
199
  public MainWindow() {
200
    mStatusBar = createStatusBar();
201
    mLineNumberText = createLineNumberText();
202
    mFindTextField = createFindTextField();
203
    mScene = createScene();
204
    mSpellChecker = createSpellChecker();
205
206
    // Add the close request listener before the window is shown.
207
    initLayout();
208
    StatusBarNotifier.setStatusBar( mStatusBar );
209
  }
210
211
  /**
212
   * Called after the stage is shown.
213
   */
214
  public void init() {
215
    initFindInput();
216
    initSnitch();
217
    initDefinitionListener();
218
    initTabAddedListener();
219
    initTabChangedListener();
220
    initPreferences();
221
    initVariableNameInjector();
222
  }
223
224
  private void initLayout() {
225
    final var scene = getScene();
226
227
    scene.getStylesheets().add( STYLESHEET_SCENE );
228
    scene.windowProperty().addListener(
229
        ( unused, oldWindow, newWindow ) ->
230
            newWindow.setOnCloseRequest(
231
                e -> {
232
                  if( !getFileEditorPane().closeAllEditors() ) {
233
                    e.consume();
234
                  }
235
                }
236
            )
237
    );
238
  }
239
240
  /**
241
   * Initialize the find input text field to listen on F3, ENTER, and
242
   * ESCAPE key presses.
243
   */
244
  private void initFindInput() {
245
    final TextField input = getFindTextField();
246
247
    input.setOnKeyPressed( ( KeyEvent event ) -> {
248
      switch( event.getCode() ) {
249
        case F3:
250
        case ENTER:
251
          editFindNext();
252
          break;
253
        case F:
254
          if( !event.isControlDown() ) {
255
            break;
256
          }
257
        case ESCAPE:
258
          getStatusBar().setGraphic( null );
259
          getActiveFileEditorTab().getEditorPane().requestFocus();
260
          break;
261
      }
262
    } );
263
264
    // Remove when the input field loses focus.
265
    input.focusedProperty().addListener(
266
        ( focused, oldFocus, newFocus ) -> {
267
          if( !newFocus ) {
268
            getStatusBar().setGraphic( null );
269
          }
270
        }
271
    );
272
  }
273
274
  /**
275
   * Watch for changes to external files. In particular, this awaits
276
   * modifications to any XSL files associated with XML files being edited.
277
   * When
278
   * an XSL file is modified (external to the application), the snitch's ears
279
   * perk up and the file is reloaded. This keeps the XSL transformation up to
280
   * date with what's on the file system.
281
   */
282
  private void initSnitch() {
283
    SNITCH.addObserver( this );
284
  }
285
286
  /**
287
   * Listen for {@link FileEditorTabPane} to receive open definition file
288
   * event.
289
   */
290
  private void initDefinitionListener() {
291
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
292
        ( final ObservableValue<? extends Path> file,
293
          final Path oldPath, final Path newPath ) -> {
294
          openDefinitions( newPath );
295
          rerender();
296
        }
297
    );
298
  }
299
300
  /**
301
   * Re-instantiates all processors then re-renders the active tab. This
302
   * will refresh the resolved map, force R to re-initialize, and brute-force
303
   * XSLT file reloads.
304
   */
305
  private void rerender() {
306
    runLater(
307
        () -> {
308
          resetProcessors();
309
          renderActiveTab();
310
        }
311
    );
312
  }
313
314
  /**
315
   * When tabs are added, hook the various change listeners onto the new
316
   * tab sothat the preview pane refreshes as necessary.
317
   */
318
  private void initTabAddedListener() {
319
    final FileEditorTabPane editorPane = getFileEditorPane();
320
321
    // Make sure the text processor kicks off when new files are opened.
322
    final ObservableList<Tab> tabs = editorPane.getTabs();
323
324
    // Update the preview pane on tab changes.
325
    tabs.addListener(
326
        ( final Change<? extends Tab> change ) -> {
327
          while( change.next() ) {
328
            if( change.wasAdded() ) {
329
              // Multiple tabs can be added simultaneously.
330
              for( final Tab newTab : change.getAddedSubList() ) {
331
                final FileEditorTab tab = (FileEditorTab) newTab;
332
333
                initTextChangeListener( tab );
334
                initScrollEventListener( tab );
335
                initSpellCheckListener( tab );
336
//              initSyntaxListener( tab );
337
              }
338
            }
339
          }
340
        }
341
    );
342
  }
343
344
  private void initTextChangeListener( final FileEditorTab tab ) {
345
    tab.addTextChangeListener(
346
        ( __, ov, nv ) -> {
347
          process( tab );
348
          scrollToParagraph( getCurrentParagraphIndex() );
349
        }
350
    );
351
  }
352
353
  private void initScrollEventListener( final FileEditorTab tab ) {
354
    final var scrollPane = tab.getScrollPane();
355
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
356
357
    addShowListener( scrollPane, ( __ ) -> {
358
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
359
      handler.enabledProperty().bind( tab.selectedProperty() );
360
    } );
361
  }
362
363
  /**
364
   * Listen for changes to the any particular paragraph and perform a quick
365
   * spell check upon it. The style classes in the editor will be changed to
366
   * mark any spelling mistakes in the paragraph. The user may then interact
367
   * with any misspelled word (i.e., any piece of text that is marked) to
368
   * revise the spelling.
369
   *
370
   * @param tab The tab to spellcheck.
371
   */
372
  private void initSpellCheckListener( final FileEditorTab tab ) {
373
    final var editor = tab.getEditorPane().getEditor();
374
375
    // When the editor first appears, run a full spell check. This allows
376
    // spell checking while typing to be restricted to the active paragraph,
377
    // which is usually substantially smaller than the whole document.
378
    addShowListener(
379
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
380
    );
381
382
    // Use the plain text changes so that notifications of style changes
383
    // are suppressed. Checking against the identity ensures that only
384
    // new text additions or deletions trigger proofreading.
385
    editor.plainTextChanges()
386
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
387
388
      // Only perform a spell check on the current paragraph. The
389
      // entire document is processed once, when opened.
390
      final var offset = change.getPosition();
391
      final var position = editor.offsetToPosition( offset, Forward );
392
      final var paraId = position.getMajor();
393
      final var paragraph = editor.getParagraph( paraId );
394
      final var text = paragraph.getText();
395
396
      // Ensure that styles aren't doubled-up.
397
      editor.clearStyle( paraId );
398
399
      spellcheck( editor, text, paraId );
400
    } );
401
  }
402
403
  /**
404
   * Listen for new tab selection events.
405
   */
406
  private void initTabChangedListener() {
407
    final FileEditorTabPane editorPane = getFileEditorPane();
408
409
    // Update the preview pane changing tabs.
410
    editorPane.addTabSelectionListener(
411
        ( tabPane, oldTab, newTab ) -> {
412
          if( newTab == null ) {
413
            // Clear the preview pane when closing an editor. When the last
414
            // tab is closed, this ensures that the preview pane is empty.
415
            getPreviewPane().clear();
416
          }
417
          else {
418
            final var tab = (FileEditorTab) newTab;
419
            updateVariableNameInjector( tab );
420
            process( tab );
421
          }
422
        }
423
    );
424
  }
425
426
  /**
427
   * Reloads the preferences from the previous session.
428
   */
429
  private void initPreferences() {
430
    initDefinitionPane();
431
    getFileEditorPane().initPreferences();
432
    getUserPreferences().addSaveEventHandler( mRPreferencesListener );
433
  }
434
435
  private void initVariableNameInjector() {
436
    updateVariableNameInjector( getActiveFileEditorTab() );
437
  }
438
439
  /**
440
   * Calls the listener when the given node is shown for the first time. The
441
   * visible property is not the same as the initial showing event; visibility
442
   * can be triggered numerous times (such as going off screen).
443
   * <p>
444
   * This is called, for example, before the drag handler can be attached,
445
   * because the scrollbar for the text editor pane must be visible.
446
   * </p>
447
   *
448
   * @param node     The node to watch for showing.
449
   * @param consumer The consumer to invoke when the event fires.
450
   */
451
  private void addShowListener(
452
      final Node node, final Consumer<Void> consumer ) {
453
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
454
        runLater( () -> {
455
          if( newShow ) {
456
            try {
457
              consumer.accept( null );
458
            } catch( final Exception ex ) {
459
              alert( ex );
460
            }
461
          }
462
        } );
463
464
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
465
       .flatMap( Window::showingProperty )
466
       .addListener( listener );
467
  }
468
469
  private void scrollToParagraph( final int id ) {
470
    scrollToParagraph( id, false );
471
  }
472
473
  /**
474
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
475
   *              exist.
476
   * @param force {@code true} means to force scrolling immediately, which
477
   *              should only be attempted when it is known that the document
478
   *              has been fully rendered. Otherwise the internal map of ID
479
   *              attributes will be incomplete and scrolling will flounder.
480
   */
481
  private void scrollToParagraph( final int id, final boolean force ) {
482
    synchronized( mMutex ) {
483
      final var previewPane = getPreviewPane();
484
      final var scrollPane = previewPane.getScrollPane();
485
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
486
487
      if( force ) {
488
        previewPane.scrollTo( approxId );
489
      }
490
      else {
491
        previewPane.tryScrollTo( approxId );
492
      }
493
494
      scrollPane.repaint();
495
    }
496
  }
497
498
  private void updateVariableNameInjector( final FileEditorTab tab ) {
499
    getVariableNameInjector().addListener( tab );
500
  }
501
502
  /**
503
   * Called whenever the preview pane becomes out of sync with the file editor
504
   * tab. This can be called when the text changes, the caret paragraph
505
   * changes, or the file tab changes.
506
   *
507
   * @param tab The file editor tab that has been changed in some fashion.
508
   */
509
  private void process( final FileEditorTab tab ) {
510
    if( tab != null ) {
511
      getPreviewPane().setPath( tab.getPath() );
512
513
      final Processor<String> processor = getProcessors().computeIfAbsent(
514
          tab, p -> createProcessors( tab )
515
      );
516
517
      try {
518
        processChain( processor, tab.getEditorText() );
519
      } catch( final Exception ex ) {
520
        alert( ex );
521
      }
522
    }
523
  }
524
525
  /**
526
   * Executes the processing chain, operating on the given string.
527
   *
528
   * @param handler The first processor in the chain to call.
529
   * @param text    The initial value of the text to process.
530
   * @return The final value of the text that was processed by the chain.
531
   */
532
  private String processChain( Processor<String> handler, String text ) {
533
    while( handler != null && text != null ) {
534
      text = handler.apply( text );
535
      handler = handler.next();
536
    }
537
538
    return text;
539
  }
540
541
  private void renderActiveTab() {
542
    process( getActiveFileEditorTab() );
543
  }
544
545
  /**
546
   * Called when a definition source is opened.
547
   *
548
   * @param path Path to the definition source that was opened.
549
   */
550
  private void openDefinitions( final Path path ) {
551
    try {
552
      final var ds = createDefinitionSource( path );
553
      setDefinitionSource( ds );
554
555
      final var prefs = getUserPreferences();
556
      prefs.definitionPathProperty().setValue( path.toFile() );
557
      prefs.save();
558
559
      final var tooltipPath = new Tooltip( path.toString() );
560
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
561
562
      final var pane = getDefinitionPane();
563
      pane.update( ds );
564
      pane.addTreeChangeHandler( mTreeHandler );
565
      pane.addKeyEventHandler( mDefinitionKeyHandler );
566
      pane.filenameProperty().setValue( path.getFileName().toString() );
567
      pane.setTooltip( tooltipPath );
568
569
      interpolateResolvedMap();
570
    } catch( final Exception ex ) {
571
      alert( ex );
572
    }
573
  }
574
575
  private void exportDefinitions( final Path path ) {
576
    try {
577
      final var pane = getDefinitionPane();
578
      final var root = pane.getTreeView().getRoot();
579
      final var problemChild = pane.isTreeWellFormed();
580
581
      if( problemChild == null ) {
582
        getDefinitionSource().getTreeAdapter().export( root, path );
583
      }
584
      else {
585
        alert( "yaml.error.tree.form", problemChild.getValue() );
586
      }
587
    } catch( final Exception ex ) {
588
      alert( ex );
589
    }
590
  }
591
592
  private void interpolateResolvedMap() {
593
    final var treeMap = getDefinitionPane().toMap();
594
    final var map = new HashMap<>( treeMap );
595
    MapInterpolator.interpolate( map );
596
597
    getResolvedMap().clear();
598
    getResolvedMap().putAll( map );
599
  }
600
601
  private void initDefinitionPane() {
602
    openDefinitions( getDefinitionPath() );
603
  }
604
605
  //---- File actions -------------------------------------------------------
606
607
  /**
608
   * Called when an {@link Observable} instance has changed. This is called
609
   * by both the {@link Snitch} service and the notify service. The @link
610
   * Snitch} service can be called for different file types, including
611
   * {@link DefinitionSource} instances.
612
   *
613
   * @param observable The observed instance.
614
   * @param value      The noteworthy item.
615
   */
616
  @Override
617
  public void update( final Observable observable, final Object value ) {
618
    if( value instanceof Path && observable instanceof Snitch ) {
619
      updateSelectedTab();
620
    }
621
  }
622
623
  /**
624
   * Called when a file has been modified.
625
   */
626
  private void updateSelectedTab() {
627
    rerender();
628
  }
629
630
  /**
631
   * After resetting the processors, they will refresh anew to be up-to-date
632
   * with the files (text and definition) currently loaded into the editor.
633
   */
634
  private void resetProcessors() {
635
    getProcessors().clear();
636
  }
637
638
  //---- File actions -------------------------------------------------------
639
640
  private void fileNew() {
641
    getFileEditorPane().newEditor();
642
  }
643
644
  private void fileOpen() {
645
    getFileEditorPane().openFileDialog();
646
  }
647
648
  private void fileClose() {
649
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
650
  }
651
652
  /**
653
   * TODO: Upon closing, first remove the tab change listeners. (There's no
654
   * need to re-render each tab when all are being closed.)
655
   */
656
  private void fileCloseAll() {
657
    getFileEditorPane().closeAllEditors();
658
  }
659
660
  private void fileSave() {
661
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
662
  }
663
664
  private void fileSaveAs() {
665
    final FileEditorTab editor = getActiveFileEditorTab();
666
    getFileEditorPane().saveEditorAs( editor );
667
    getProcessors().remove( editor );
668
669
    try {
670
      process( editor );
671
    } catch( final Exception ex ) {
672
      alert( ex );
673
    }
674
  }
675
676
  private void fileSaveAll() {
677
    getFileEditorPane().saveAllEditors();
678
  }
679
680
  private void fileExit() {
681
    final Window window = getWindow();
682
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
683
  }
684
685
  //---- Edit actions -------------------------------------------------------
686
687
  /**
688
   * Transform the Markdown into HTML then copy that HTML into the copy
689
   * buffer.
690
   */
691
  private void copyHtml() {
692
    final var markdown = getActiveEditorPane().getText();
693
    final var processors = createProcessorFactory().createProcessors(
694
        getActiveFileEditorTab()
695
    );
696
697
    final var chain = processors.remove( HtmlPreviewProcessor.class );
698
699
    final String html = processChain( chain, markdown );
700
701
    final Clipboard clipboard = Clipboard.getSystemClipboard();
702
    final ClipboardContent content = new ClipboardContent();
703
    content.putString( html );
704
    clipboard.setContent( content );
705
  }
706
707
  /**
708
   * Used to find text in the active file editor window.
709
   */
710
  private void editFind() {
711
    final TextField input = getFindTextField();
712
    getStatusBar().setGraphic( input );
713
    input.requestFocus();
714
  }
715
716
  public void editFindNext() {
717
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
718
  }
719
720
  public void editPreferences() {
721
    getUserPreferences().show();
722
  }
723
724
  //---- Insert actions -----------------------------------------------------
725
726
  /**
727
   * Delegates to the active editor to handle wrapping the current text
728
   * selection with leading and trailing strings.
729
   *
730
   * @param leading  The string to put before the selection.
731
   * @param trailing The string to put after the selection.
732
   */
733
  private void insertMarkdown(
734
      final String leading, final String trailing ) {
735
    getActiveEditorPane().surroundSelection( leading, trailing );
736
  }
737
738
  private void insertMarkdown(
739
      final String leading, final String trailing, final String hint ) {
740
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
741
  }
742
743
  //---- View actions -------------------------------------------------------
744
745
  private void viewRefresh() {
746
    rerender();
747
  }
748
749
  //---- Help actions -------------------------------------------------------
750
751
  private void helpAbout() {
752
    final Alert alert = new Alert( AlertType.INFORMATION );
753
    alert.setTitle( get( "Dialog.about.title" ) );
754
    alert.setHeaderText( get( "Dialog.about.header" ) );
755
    alert.setContentText( get( "Dialog.about.content" ) );
756
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
757
    alert.initOwner( getWindow() );
758
759
    alert.showAndWait();
760
  }
761
762
  //---- Member creators ----------------------------------------------------
763
764
  private SpellChecker createSpellChecker() {
765
    try {
766
      final Collection<String> lexicon = readLexicon( "en.txt" );
767
      return SymSpellSpeller.forLexicon( lexicon );
768
    } catch( final Exception ex ) {
769
      alert( ex );
770
      return new PermissiveSpeller();
771
    }
772
  }
773
774
  /**
775
   * Factory to create processors that are suited to different file types.
776
   *
777
   * @param tab The tab that is subjected to processing.
778
   * @return A processor suited to the file type specified by the tab's path.
779
   */
780
  private Processor<String> createProcessors( final FileEditorTab tab ) {
781
    return createProcessorFactory().createProcessors( tab );
782
  }
783
784
  private ProcessorFactory createProcessorFactory() {
785
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
786
  }
787
788
  private HTMLPreviewPane createHTMLPreviewPane() {
789
    return new HTMLPreviewPane();
790
  }
791
792
  private DefinitionSource createDefaultDefinitionSource() {
793
    return new YamlDefinitionSource( getDefinitionPath() );
794
  }
795
796
  private DefinitionSource createDefinitionSource( final Path path ) {
797
    try {
798
      return createDefinitionFactory().createDefinitionSource( path );
799
    } catch( final Exception ex ) {
800
      alert( ex );
801
      return createDefaultDefinitionSource();
802
    }
803
  }
804
805
  private TextField createFindTextField() {
806
    return new TextField();
807
  }
808
809
  private DefinitionFactory createDefinitionFactory() {
810
    return new DefinitionFactory();
811
  }
812
813
  private StatusBar createStatusBar() {
814
    return new StatusBar();
815
  }
816
817
  private Scene createScene() {
818
    final SplitPane splitPane = new SplitPane(
819
        getDefinitionPane(),
820
        getFileEditorPane(),
821
        getPreviewPane() );
822
823
    splitPane.setDividerPositions(
824
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
825
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
826
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
827
828
    getDefinitionPane().prefHeightProperty()
829
                       .bind( splitPane.heightProperty() );
830
831
    final BorderPane borderPane = new BorderPane();
832
    borderPane.setPrefSize( 1280, 800 );
833
    borderPane.setTop( createMenuBar() );
834
    borderPane.setBottom( getStatusBar() );
835
    borderPane.setCenter( splitPane );
836
837
    final VBox statusBar = new VBox();
838
    statusBar.setAlignment( Pos.BASELINE_CENTER );
839
    statusBar.getChildren().add( getLineNumberText() );
840
    getStatusBar().getRightItems().add( statusBar );
841
842
    // Force preview pane refresh on Windows.
843
    if( SystemUtils.IS_OS_WINDOWS ) {
844
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
845
          ( l, oValue, nValue ) -> runLater(
846
              () -> getPreviewPane().getScrollPane().repaint()
847
          )
848
      );
849
    }
850
851
    return new Scene( borderPane );
852
  }
853
854
  private Text createLineNumberText() {
855
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
856
  }
857
858
  private Node createMenuBar() {
859
    final BooleanBinding activeFileEditorIsNull =
860
        getFileEditorPane().activeFileEditorProperty().isNull();
861
862
    // File actions
863
    final Action fileNewAction = new ActionBuilder()
864
        .setText( "Main.menu.file.new" )
865
        .setAccelerator( "Shortcut+N" )
866
        .setIcon( FILE_ALT )
867
        .setAction( e -> fileNew() )
868
        .build();
869
    final Action fileOpenAction = new ActionBuilder()
870
        .setText( "Main.menu.file.open" )
871
        .setAccelerator( "Shortcut+O" )
872
        .setIcon( FOLDER_OPEN_ALT )
873
        .setAction( e -> fileOpen() )
874
        .build();
875
    final Action fileCloseAction = new ActionBuilder()
876
        .setText( "Main.menu.file.close" )
877
        .setAccelerator( "Shortcut+W" )
878
        .setAction( e -> fileClose() )
879
        .setDisable( activeFileEditorIsNull )
880
        .build();
881
    final Action fileCloseAllAction = new ActionBuilder()
882
        .setText( "Main.menu.file.close_all" )
883
        .setAction( e -> fileCloseAll() )
884
        .setDisable( activeFileEditorIsNull )
885
        .build();
886
    final Action fileSaveAction = new ActionBuilder()
887
        .setText( "Main.menu.file.save" )
888
        .setAccelerator( "Shortcut+S" )
889
        .setIcon( FLOPPY_ALT )
890
        .setAction( e -> fileSave() )
891
        .setDisable( createActiveBooleanProperty(
892
            FileEditorTab::modifiedProperty ).not() )
893
        .build();
894
    final Action fileSaveAsAction = new ActionBuilder()
895
        .setText( "Main.menu.file.save_as" )
896
        .setAction( e -> fileSaveAs() )
897
        .setDisable( activeFileEditorIsNull )
898
        .build();
899
    final Action fileSaveAllAction = new ActionBuilder()
900
        .setText( "Main.menu.file.save_all" )
901
        .setAccelerator( "Shortcut+Shift+S" )
902
        .setAction( e -> fileSaveAll() )
903
        .setDisable( Bindings.not(
904
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
905
        .build();
906
    final Action fileExitAction = new ActionBuilder()
907
        .setText( "Main.menu.file.exit" )
908
        .setAction( e -> fileExit() )
909
        .build();
910
911
    // Edit actions
912
    final Action editCopyHtmlAction = new ActionBuilder()
913
        .setText( "Main.menu.edit.copy.html" )
914
        .setIcon( HTML5 )
915
        .setAction( e -> copyHtml() )
916
        .setDisable( activeFileEditorIsNull )
917
        .build();
918
919
    final Action editUndoAction = new ActionBuilder()
920
        .setText( "Main.menu.edit.undo" )
921
        .setAccelerator( "Shortcut+Z" )
922
        .setIcon( UNDO )
923
        .setAction( e -> getActiveEditorPane().undo() )
924
        .setDisable( createActiveBooleanProperty(
925
            FileEditorTab::canUndoProperty ).not() )
926
        .build();
927
    final Action editRedoAction = new ActionBuilder()
928
        .setText( "Main.menu.edit.redo" )
929
        .setAccelerator( "Shortcut+Y" )
930
        .setIcon( REPEAT )
931
        .setAction( e -> getActiveEditorPane().redo() )
932
        .setDisable( createActiveBooleanProperty(
933
            FileEditorTab::canRedoProperty ).not() )
934
        .build();
935
936
    final Action editCutAction = new ActionBuilder()
937
        .setText( "Main.menu.edit.cut" )
938
        .setAccelerator( "Shortcut+X" )
939
        .setIcon( CUT )
940
        .setAction( e -> getActiveEditorPane().cut() )
941
        .setDisable( activeFileEditorIsNull )
942
        .build();
943
    final Action editCopyAction = new ActionBuilder()
944
        .setText( "Main.menu.edit.copy" )
945
        .setAccelerator( "Shortcut+C" )
946
        .setIcon( COPY )
947
        .setAction( e -> getActiveEditorPane().copy() )
948
        .setDisable( activeFileEditorIsNull )
949
        .build();
950
    final Action editPasteAction = new ActionBuilder()
951
        .setText( "Main.menu.edit.paste" )
952
        .setAccelerator( "Shortcut+V" )
953
        .setIcon( PASTE )
954
        .setAction( e -> getActiveEditorPane().paste() )
955
        .setDisable( activeFileEditorIsNull )
956
        .build();
957
    final Action editSelectAllAction = new ActionBuilder()
958
        .setText( "Main.menu.edit.selectAll" )
959
        .setAccelerator( "Shortcut+A" )
960
        .setAction( e -> getActiveEditorPane().selectAll() )
961
        .setDisable( activeFileEditorIsNull )
962
        .build();
963
964
    final Action editFindAction = new ActionBuilder()
965
        .setText( "Main.menu.edit.find" )
966
        .setAccelerator( "Ctrl+F" )
967
        .setIcon( SEARCH )
968
        .setAction( e -> editFind() )
969
        .setDisable( activeFileEditorIsNull )
970
        .build();
971
    final Action editFindNextAction = new ActionBuilder()
972
        .setText( "Main.menu.edit.find.next" )
973
        .setAccelerator( "F3" )
974
        .setIcon( null )
975
        .setAction( e -> editFindNext() )
976
        .setDisable( activeFileEditorIsNull )
977
        .build();
978
    final Action editPreferencesAction = new ActionBuilder()
979
        .setText( "Main.menu.edit.preferences" )
980
        .setAccelerator( "Ctrl+Alt+S" )
981
        .setAction( e -> editPreferences() )
982
        .build();
983
984
    // Format actions
985
    final Action formatBoldAction = new ActionBuilder()
986
        .setText( "Main.menu.format.bold" )
987
        .setAccelerator( "Shortcut+B" )
988
        .setIcon( BOLD )
989
        .setAction( e -> insertMarkdown( "**", "**" ) )
990
        .setDisable( activeFileEditorIsNull )
991
        .build();
992
    final Action formatItalicAction = new ActionBuilder()
993
        .setText( "Main.menu.format.italic" )
994
        .setAccelerator( "Shortcut+I" )
995
        .setIcon( ITALIC )
996
        .setAction( e -> insertMarkdown( "*", "*" ) )
997
        .setDisable( activeFileEditorIsNull )
998
        .build();
999
    final Action formatSuperscriptAction = new ActionBuilder()
1000
        .setText( "Main.menu.format.superscript" )
1001
        .setAccelerator( "Shortcut+[" )
1002
        .setIcon( SUPERSCRIPT )
1003
        .setAction( e -> insertMarkdown( "^", "^" ) )
1004
        .setDisable( activeFileEditorIsNull )
1005
        .build();
1006
    final Action formatSubscriptAction = new ActionBuilder()
1007
        .setText( "Main.menu.format.subscript" )
1008
        .setAccelerator( "Shortcut+]" )
1009
        .setIcon( SUBSCRIPT )
1010
        .setAction( e -> insertMarkdown( "~", "~" ) )
1011
        .setDisable( activeFileEditorIsNull )
1012
        .build();
1013
    final Action formatStrikethroughAction = new ActionBuilder()
1014
        .setText( "Main.menu.format.strikethrough" )
1015
        .setAccelerator( "Shortcut+T" )
1016
        .setIcon( STRIKETHROUGH )
1017
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1018
        .setDisable( activeFileEditorIsNull )
1019
        .build();
1020
1021
    // Insert actions
1022
    final Action insertBlockquoteAction = new ActionBuilder()
1023
        .setText( "Main.menu.insert.blockquote" )
1024
        .setAccelerator( "Ctrl+Q" )
1025
        .setIcon( QUOTE_LEFT )
1026
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1027
        .setDisable( activeFileEditorIsNull )
1028
        .build();
1029
    final Action insertCodeAction = new ActionBuilder()
1030
        .setText( "Main.menu.insert.code" )
1031
        .setAccelerator( "Shortcut+K" )
1032
        .setIcon( CODE )
1033
        .setAction( e -> insertMarkdown( "`", "`" ) )
1034
        .setDisable( activeFileEditorIsNull )
1035
        .build();
1036
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1037
        .setText( "Main.menu.insert.fenced_code_block" )
1038
        .setAccelerator( "Shortcut+Shift+K" )
1039
        .setIcon( FILE_CODE_ALT )
1040
        .setAction( e -> insertMarkdown(
1041
            "\n\n```\n",
1042
            "\n```\n\n",
1043
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1044
        .setDisable( activeFileEditorIsNull )
1045
        .build();
1046
    final Action insertLinkAction = new ActionBuilder()
1047
        .setText( "Main.menu.insert.link" )
1048
        .setAccelerator( "Shortcut+L" )
1049
        .setIcon( LINK )
1050
        .setAction( e -> getActiveEditorPane().insertLink() )
1051
        .setDisable( activeFileEditorIsNull )
1052
        .build();
1053
    final Action insertImageAction = new ActionBuilder()
1054
        .setText( "Main.menu.insert.image" )
1055
        .setAccelerator( "Shortcut+G" )
1056
        .setIcon( PICTURE_ALT )
1057
        .setAction( e -> getActiveEditorPane().insertImage() )
1058
        .setDisable( activeFileEditorIsNull )
1059
        .build();
1060
1061
    // Number of heading actions (H1 ... H3)
1062
    final int HEADINGS = 3;
1063
    final Action[] headings = new Action[ HEADINGS ];
1064
1065
    for( int i = 1; i <= HEADINGS; i++ ) {
1066
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1067
      final String markup = String.format( "%n%n%s ", hashes );
1068
      final String text = "Main.menu.insert.heading." + i;
1069
      final String accelerator = "Shortcut+" + i;
1070
      final String prompt = text + ".prompt";
1071
1072
      headings[ i - 1 ] = new ActionBuilder()
1073
          .setText( text )
1074
          .setAccelerator( accelerator )
1075
          .setIcon( HEADER )
1076
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1077
          .setDisable( activeFileEditorIsNull )
1078
          .build();
1079
    }
1080
1081
    final Action insertUnorderedListAction = new ActionBuilder()
1082
        .setText( "Main.menu.insert.unordered_list" )
1083
        .setAccelerator( "Shortcut+U" )
1084
        .setIcon( LIST_UL )
1085
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1086
        .setDisable( activeFileEditorIsNull )
1087
        .build();
1088
    final Action insertOrderedListAction = new ActionBuilder()
1089
        .setText( "Main.menu.insert.ordered_list" )
1090
        .setAccelerator( "Shortcut+Shift+O" )
1091
        .setIcon( LIST_OL )
1092
        .setAction( e -> insertMarkdown(
1093
            "\n\n1. ", "" ) )
1094
        .setDisable( activeFileEditorIsNull )
1095
        .build();
1096
    final Action insertHorizontalRuleAction = new ActionBuilder()
1097
        .setText( "Main.menu.insert.horizontal_rule" )
1098
        .setAccelerator( "Shortcut+H" )
1099
        .setAction( e -> insertMarkdown(
1100
            "\n\n---\n\n", "" ) )
1101
        .setDisable( activeFileEditorIsNull )
1102
        .build();
1103
1104
    // Definition actions
1105
    final Action definitionCreateAction = new ActionBuilder()
1106
        .setText( "Main.menu.definition.create" )
1107
        .setIcon( TREE )
1108
        .setAction( e -> getDefinitionPane().addItem() )
1109
        .build();
1110
    final Action definitionInsertAction = new ActionBuilder()
1111
        .setText( "Main.menu.definition.insert" )
1112
        .setAccelerator( "Ctrl+Space" )
1113
        .setIcon( STAR )
1114
        .setAction( e -> definitionInsert() )
1115
        .build();
1116
1117
    // Help actions
1118
    final Action helpAboutAction = new ActionBuilder()
1119
        .setText( "Main.menu.help.about" )
1120
        .setAction( e -> helpAbout() )
1121
        .build();
1122
1123
    //---- MenuBar ----
1124
1125
    // File Menu
1126
    final var fileMenu = ActionUtils.createMenu(
1127
        get( "Main.menu.file" ),
1128
        fileNewAction,
1129
        fileOpenAction,
1130
        null,
1131
        fileCloseAction,
1132
        fileCloseAllAction,
1133
        null,
1134
        fileSaveAction,
1135
        fileSaveAsAction,
1136
        fileSaveAllAction,
1137
        null,
1138
        fileExitAction );
1139
1140
    // Edit Menu
1141
    final var editMenu = ActionUtils.createMenu(
1142
        get( "Main.menu.edit" ),
1143
        editCopyHtmlAction,
1144
        null,
1145
        editUndoAction,
1146
        editRedoAction,
1147
        null,
1148
        editCutAction,
1149
        editCopyAction,
1150
        editPasteAction,
1151
        editSelectAllAction,
1152
        null,
1153
        editFindAction,
1154
        editFindNextAction,
1155
        null,
1156
        editPreferencesAction );
1157
1158
    // Format Menu
1159
    final var formatMenu = ActionUtils.createMenu(
1160
        get( "Main.menu.format" ),
1161
        formatBoldAction,
1162
        formatItalicAction,
1163
        formatSuperscriptAction,
1164
        formatSubscriptAction,
1165
        formatStrikethroughAction
1166
    );
1167
1168
    // Insert Menu
1169
    final var insertMenu = ActionUtils.createMenu(
1170
        get( "Main.menu.insert" ),
1171
        insertBlockquoteAction,
1172
        insertCodeAction,
1173
        insertFencedCodeBlockAction,
1174
        null,
1175
        insertLinkAction,
1176
        insertImageAction,
1177
        null,
1178
        headings[ 0 ],
1179
        headings[ 1 ],
1180
        headings[ 2 ],
1181
        null,
1182
        insertUnorderedListAction,
1183
        insertOrderedListAction,
1184
        insertHorizontalRuleAction
1185
    );
1186
1187
    // Definition Menu
1188
    final var definitionMenu = ActionUtils.createMenu(
1189
        get( "Main.menu.definition" ),
1190
        definitionCreateAction,
1191
        definitionInsertAction );
1192
1193
    // Help Menu
1194
    final var helpMenu = ActionUtils.createMenu(
1195
        get( "Main.menu.help" ),
1196
        helpAboutAction );
1197
1198
    //---- MenuBar ----
1199
    final var menuBar = new MenuBar(
1200
        fileMenu,
1201
        editMenu,
1202
        formatMenu,
1203
        insertMenu,
1204
        definitionMenu,
1205
        helpMenu );
1206
1207
    //---- ToolBar ----
1208
    final var toolBar = ActionUtils.createToolBar(
1209
        fileNewAction,
1210
        fileOpenAction,
1211
        fileSaveAction,
1212
        null,
1213
        editUndoAction,
1214
        editRedoAction,
1215
        editCutAction,
1216
        editCopyAction,
1217
        editPasteAction,
1218
        null,
1219
        formatBoldAction,
1220
        formatItalicAction,
1221
        formatSuperscriptAction,
1222
        formatSubscriptAction,
1223
        insertBlockquoteAction,
1224
        insertCodeAction,
1225
        insertFencedCodeBlockAction,
1226
        null,
1227
        insertLinkAction,
1228
        insertImageAction,
1229
        null,
1230
        headings[ 0 ],
1231
        null,
1232
        insertUnorderedListAction,
1233
        insertOrderedListAction );
1234
1235
    return new VBox( menuBar, toolBar );
1236
  }
1237
1238
  /**
1239
   * Performs the autoinsert function on the active file editor.
1240
   */
1241
  private void definitionInsert() {
1242
  }
1243
1244
  /**
1245
   * Creates a boolean property that is bound to another boolean value of the
1246
   * active editor.
1247
   */
1248
  private BooleanProperty createActiveBooleanProperty(
1249
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1250
1251
    final BooleanProperty b = new SimpleBooleanProperty();
1252
    final FileEditorTab tab = getActiveFileEditorTab();
1253
1254
    if( tab != null ) {
1255
      b.bind( func.apply( tab ) );
1256
    }
1257
1258
    getFileEditorPane().activeFileEditorProperty().addListener(
1259
        ( observable, oldFileEditor, newFileEditor ) -> {
1260
          b.unbind();
1261
1262
          if( newFileEditor == null ) {
1263
            b.set( false );
1264
          }
1265
          else {
1266
            b.bind( func.apply( newFileEditor ) );
1267
          }
1268
        }
1269
    );
1270
1271
    return b;
1272
  }
1273
1274
  //---- Convenience accessors ----------------------------------------------
1275
1276
  private Preferences getPreferences() {
1277
    return sOptions.getState();
1278
  }
1279
1280
  private int getCurrentParagraphIndex() {
1281
    return getActiveEditorPane().getCurrentParagraphIndex();
1282
  }
1283
1284
  private float getFloat( final String key, final float defaultValue ) {
1285
    return getPreferences().getFloat( key, defaultValue );
1286
  }
1287
1288
  public Window getWindow() {
1289
    return getScene().getWindow();
1290
  }
1291
1292
  private MarkdownEditorPane getActiveEditorPane() {
1293
    return getActiveFileEditorTab().getEditorPane();
1294
  }
1295
1296
  private FileEditorTab getActiveFileEditorTab() {
1297
    return getFileEditorPane().getActiveFileEditor();
1298
  }
1299
1300
  //---- Member accessors ---------------------------------------------------
1301
1302
  protected Scene getScene() {
1303
    return mScene;
1304
  }
1305
1306
  private SpellChecker getSpellChecker() {
1307
    return mSpellChecker;
1308
  }
1309
1310
  private Map<FileEditorTab, Processor<String>> getProcessors() {
1311
    return mProcessors;
1312
  }
1313
1314
  private FileEditorTabPane getFileEditorPane() {
1315
    return mFileEditorPane;
1316
  }
1317
1318
  private HTMLPreviewPane getPreviewPane() {
1319
    return mPreviewPane;
1320
  }
1321
1322
  private void setDefinitionSource(
1323
      final DefinitionSource definitionSource ) {
1324
    assert definitionSource != null;
1325
    mDefinitionSource = definitionSource;
1326
  }
1327
1328
  private DefinitionSource getDefinitionSource() {
1329
    return mDefinitionSource;
1330
  }
1331
1332
  private DefinitionPane getDefinitionPane() {
1333
    return mDefinitionPane;
1334
  }
1335
1336
  private Text getLineNumberText() {
1337
    return mLineNumberText;
1338
  }
1339
1340
  private StatusBar getStatusBar() {
1341
    return mStatusBar;
1342
  }
1343
1344
  private TextField getFindTextField() {
1345
    return mFindTextField;
1346
  }
1347
1348
  private DefinitionNameInjector getVariableNameInjector() {
1349
    return mVariableNameInjector;
1350
  }
1351
1352
  /**
1353
   * Returns the variable map of interpolated definitions.
1354
   *
1355
   * @return A map to help dereference variables.
1356
   */
1357
  private Map<String, String> getResolvedMap() {
1358
    return mResolvedMap;
1359
  }
1360
1361
  //---- Persistence accessors ----------------------------------------------
1362
1363
  private UserPreferences getUserPreferences() {
1364
    return sOptions.getUserPreferences();
1365
  }
1366
1367
  private Path getDefinitionPath() {
1368
    return getUserPreferences().getDefinitionPath();
1369
  }
1370
1371
  //---- Spelling -----------------------------------------------------------
1372
1373
  /**
1374
   * Delegates to {@link #spellcheck(StyleClassedTextArea, String, int)}.
1375
   * This is called to spell check the document, rather than a single paragraph.
1376
   *
1377
   * @param text The full document text.
1378
   */
1379
  private void spellcheck(
1380
      final StyleClassedTextArea editor, final String text ) {
1381
    spellcheck( editor, text, -1 );
1382
  }
1383
1384
  /**
1385
   * Spellchecks a subset of the entire document.
1386
   *
1387
   * @param text   Look up words for this text in the lexicon.
1388
   * @param paraId Set to -1 to apply resulting style spans to the entire
1389
   *               text.
1390
   */
1391
  private void spellcheck(
1392
      final StyleClassedTextArea editor, final String text, final int paraId ) {
1393
    final var builder = new StyleSpansBuilder<Collection<String>>();
1394
    final var runningIndex = new AtomicInteger( 0 );
1395
    final var checker = getSpellChecker();
1396
1397
    // The text nodes must be relayed through a contextual "visitor" that
1398
    // can return text in chunks with correlative offsets into the string.
1399
    // This allows Markdown, R Markdown, XML, and R XML documents to return
1400
    // sets of words to check.
1401
1402
    final var node = mParser.parse( text );
1403
    final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
1404
      // Treat hyphenated compound words as individual words.
1405
      final var check = visited.replace( '-', ' ' );
1406
1407
      checker.proofread( check, ( misspelled, prevIndex, currIndex ) -> {
1408
        prevIndex += bIndex;
1409
        currIndex += bIndex;
1410
1411
        // Clear styling between lexiconically absent words.
1412
        builder.add( emptyList(), prevIndex - runningIndex.get() );
1413
        builder.add( singleton( "spelling" ), currIndex - prevIndex );
1414
        runningIndex.set( currIndex );
1415
      } );
1416
    } );
1417
1418
    visitor.visit( node );
1419
1420
    // If the running index was set, at least one word triggered the listener.
1421
    if( runningIndex.get() > 0 ) {
1422
      // Clear styling after the last lexiconically absent word.
1423
      builder.add( emptyList(), text.length() - runningIndex.get() );
1424
1425
      final var spans = builder.create();
1426
1427
      if( paraId >= 0 ) {
1428
        editor.setStyleSpans( paraId, 0, spans );
1429
      }
1430
      else {
1431
        editor.setStyleSpans( 0, spans );
1432
      }
1433
    }
1434
  }
1435
1436
  @SuppressWarnings("SameParameterValue")
1437
  private Collection<String> readLexicon( final String filename )
1438
      throws Exception {
1439
    final var path = Paths.get( LEXICONS_DIRECTORY, filename ).toString();
1440
    final var classLoader = MainWindow.class.getClassLoader();
1441
1442
    try( final var resource = classLoader.getResourceAsStream( path ) ) {
1443
      assert resource != null;
1444
1445
      return new BufferedReader( new InputStreamReader( resource, UTF_8 ) )
1446
          .lines()
1447
          .collect( Collectors.toList() );
90
import java.io.FileNotFoundException;
91
import java.io.InputStreamReader;
92
import java.nio.file.Path;
93
import java.util.*;
94
import java.util.concurrent.atomic.AtomicInteger;
95
import java.util.function.Consumer;
96
import java.util.function.Function;
97
import java.util.prefs.Preferences;
98
import java.util.stream.Collectors;
99
100
import static com.scrivenvar.Constants.*;
101
import static com.scrivenvar.Messages.get;
102
import static com.scrivenvar.StatusBarNotifier.alert;
103
import static com.scrivenvar.util.StageState.*;
104
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
105
import static java.nio.charset.StandardCharsets.UTF_8;
106
import static java.util.Collections.emptyList;
107
import static java.util.Collections.singleton;
108
import static javafx.application.Platform.runLater;
109
import static javafx.event.Event.fireEvent;
110
import static javafx.scene.input.KeyCode.ENTER;
111
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
112
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
113
114
/**
115
 * Main window containing a tab pane in the center for file editors.
116
 */
117
public class MainWindow implements Observer {
118
  /**
119
   * The {@code OPTIONS} variable must be declared before all other variables
120
   * to prevent subsequent initializations from failing due to missing user
121
   * preferences.
122
   */
123
  private static final Options sOptions = Services.load( Options.class );
124
  private static final Snitch SNITCH = Services.load( Snitch.class );
125
126
  private final Scene mScene;
127
  private final StatusBar mStatusBar;
128
  private final Text mLineNumberText;
129
  private final TextField mFindTextField;
130
  private final SpellChecker mSpellChecker;
131
132
  private final Object mMutex = new Object();
133
134
  /**
135
   * Prevents re-instantiation of processing classes.
136
   */
137
  private final Map<FileEditorTab, Processor<String>> mProcessors =
138
      new HashMap<>();
139
140
  private final Map<String, String> mResolvedMap =
141
      new HashMap<>( DEFAULT_MAP_SIZE );
142
143
  private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
144
      event -> rerender();
145
146
  /**
147
   * Called when the definition data is changed.
148
   */
149
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
150
      mTreeHandler = event -> {
151
    exportDefinitions( getDefinitionPath() );
152
    interpolateResolvedMap();
153
    rerender();
154
  };
155
156
  /**
157
   * Called to inject the selected item when the user presses ENTER in the
158
   * definition pane.
159
   */
160
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
161
      event -> {
162
        if( event.getCode() == ENTER ) {
163
          getDefinitionNameInjector().injectSelectedItem();
164
        }
165
      };
166
167
  private final ChangeListener<Integer> mCaretPositionListener =
168
      ( observable, oldPosition, newPosition ) -> {
169
        final FileEditorTab tab = getActiveFileEditorTab();
170
        final EditorPane pane = tab.getEditorPane();
171
        final StyleClassedTextArea editor = pane.getEditor();
172
173
        getLineNumberText().setText(
174
            get( STATUS_BAR_LINE,
175
                 editor.getCurrentParagraph() + 1,
176
                 editor.getParagraphs().size(),
177
                 editor.getCaretPosition()
178
            )
179
        );
180
      };
181
182
  private final ChangeListener<Integer> mCaretParagraphListener =
183
      ( observable, oldIndex, newIndex ) ->
184
          scrollToParagraph( newIndex, true );
185
186
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
187
  private final DefinitionPane mDefinitionPane = createDefinitionPane();
188
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
189
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
190
      mCaretPositionListener,
191
      mCaretParagraphListener );
192
193
  /**
194
   * Listens on the definition pane for double-click events.
195
   */
196
  private final DefinitionNameInjector mDefinitionNameInjector
197
      = new DefinitionNameInjector( mDefinitionPane );
198
199
  public MainWindow() {
200
    mStatusBar = createStatusBar();
201
    mLineNumberText = createLineNumberText();
202
    mFindTextField = createFindTextField();
203
    mScene = createScene();
204
    mSpellChecker = createSpellChecker();
205
206
    // Add the close request listener before the window is shown.
207
    initLayout();
208
    StatusBarNotifier.setStatusBar( mStatusBar );
209
  }
210
211
  /**
212
   * Called after the stage is shown.
213
   */
214
  public void init() {
215
    initFindInput();
216
    initSnitch();
217
    initDefinitionListener();
218
    initTabAddedListener();
219
    initTabChangedListener();
220
    initPreferences();
221
    initVariableNameInjector();
222
  }
223
224
  private void initLayout() {
225
    final var scene = getScene();
226
227
    scene.getStylesheets().add( STYLESHEET_SCENE );
228
    scene.windowProperty().addListener(
229
        ( unused, oldWindow, newWindow ) ->
230
            newWindow.setOnCloseRequest(
231
                e -> {
232
                  if( !getFileEditorPane().closeAllEditors() ) {
233
                    e.consume();
234
                  }
235
                }
236
            )
237
    );
238
  }
239
240
  /**
241
   * Initialize the find input text field to listen on F3, ENTER, and
242
   * ESCAPE key presses.
243
   */
244
  private void initFindInput() {
245
    final TextField input = getFindTextField();
246
247
    input.setOnKeyPressed( ( KeyEvent event ) -> {
248
      switch( event.getCode() ) {
249
        case F3:
250
        case ENTER:
251
          editFindNext();
252
          break;
253
        case F:
254
          if( !event.isControlDown() ) {
255
            break;
256
          }
257
        case ESCAPE:
258
          getStatusBar().setGraphic( null );
259
          getActiveFileEditorTab().getEditorPane().requestFocus();
260
          break;
261
      }
262
    } );
263
264
    // Remove when the input field loses focus.
265
    input.focusedProperty().addListener(
266
        ( focused, oldFocus, newFocus ) -> {
267
          if( !newFocus ) {
268
            getStatusBar().setGraphic( null );
269
          }
270
        }
271
    );
272
  }
273
274
  /**
275
   * Watch for changes to external files. In particular, this awaits
276
   * modifications to any XSL files associated with XML files being edited.
277
   * When
278
   * an XSL file is modified (external to the application), the snitch's ears
279
   * perk up and the file is reloaded. This keeps the XSL transformation up to
280
   * date with what's on the file system.
281
   */
282
  private void initSnitch() {
283
    SNITCH.addObserver( this );
284
  }
285
286
  /**
287
   * Listen for {@link FileEditorTabPane} to receive open definition file
288
   * event.
289
   */
290
  private void initDefinitionListener() {
291
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
292
        ( final ObservableValue<? extends Path> file,
293
          final Path oldPath, final Path newPath ) -> {
294
          openDefinitions( newPath );
295
          rerender();
296
        }
297
    );
298
  }
299
300
  /**
301
   * Re-instantiates all processors then re-renders the active tab. This
302
   * will refresh the resolved map, force R to re-initialize, and brute-force
303
   * XSLT file reloads.
304
   */
305
  private void rerender() {
306
    runLater(
307
        () -> {
308
          resetProcessors();
309
          renderActiveTab();
310
        }
311
    );
312
  }
313
314
  /**
315
   * When tabs are added, hook the various change listeners onto the new
316
   * tab sothat the preview pane refreshes as necessary.
317
   */
318
  private void initTabAddedListener() {
319
    final FileEditorTabPane editorPane = getFileEditorPane();
320
321
    // Make sure the text processor kicks off when new files are opened.
322
    final ObservableList<Tab> tabs = editorPane.getTabs();
323
324
    // Update the preview pane on tab changes.
325
    tabs.addListener(
326
        ( final Change<? extends Tab> change ) -> {
327
          while( change.next() ) {
328
            if( change.wasAdded() ) {
329
              // Multiple tabs can be added simultaneously.
330
              for( final Tab newTab : change.getAddedSubList() ) {
331
                final FileEditorTab tab = (FileEditorTab) newTab;
332
333
                initTextChangeListener( tab );
334
                initScrollEventListener( tab );
335
                initSpellCheckListener( tab );
336
//              initSyntaxListener( tab );
337
              }
338
            }
339
          }
340
        }
341
    );
342
  }
343
344
  private void initTextChangeListener( final FileEditorTab tab ) {
345
    tab.addTextChangeListener(
346
        ( __, ov, nv ) -> {
347
          process( tab );
348
          scrollToParagraph( getCurrentParagraphIndex() );
349
        }
350
    );
351
  }
352
353
  private void initScrollEventListener( final FileEditorTab tab ) {
354
    final var scrollPane = tab.getScrollPane();
355
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
356
357
    addShowListener( scrollPane, ( __ ) -> {
358
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
359
      handler.enabledProperty().bind( tab.selectedProperty() );
360
    } );
361
  }
362
363
  /**
364
   * Listen for changes to the any particular paragraph and perform a quick
365
   * spell check upon it. The style classes in the editor will be changed to
366
   * mark any spelling mistakes in the paragraph. The user may then interact
367
   * with any misspelled word (i.e., any piece of text that is marked) to
368
   * revise the spelling.
369
   *
370
   * @param tab The tab to spellcheck.
371
   */
372
  private void initSpellCheckListener( final FileEditorTab tab ) {
373
    final var editor = tab.getEditorPane().getEditor();
374
375
    // When the editor first appears, run a full spell check. This allows
376
    // spell checking while typing to be restricted to the active paragraph,
377
    // which is usually substantially smaller than the whole document.
378
    addShowListener(
379
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
380
    );
381
382
    // Use the plain text changes so that notifications of style changes
383
    // are suppressed. Checking against the identity ensures that only
384
    // new text additions or deletions trigger proofreading.
385
    editor.plainTextChanges()
386
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
387
388
      // Only perform a spell check on the current paragraph. The
389
      // entire document is processed once, when opened.
390
      final var offset = change.getPosition();
391
      final var position = editor.offsetToPosition( offset, Forward );
392
      final var paraId = position.getMajor();
393
      final var paragraph = editor.getParagraph( paraId );
394
      final var text = paragraph.getText();
395
396
      // Ensure that styles aren't doubled-up.
397
      editor.clearStyle( paraId );
398
399
      spellcheck( editor, text, paraId );
400
    } );
401
  }
402
403
  /**
404
   * Listen for new tab selection events.
405
   */
406
  private void initTabChangedListener() {
407
    final FileEditorTabPane editorPane = getFileEditorPane();
408
409
    // Update the preview pane changing tabs.
410
    editorPane.addTabSelectionListener(
411
        ( tabPane, oldTab, newTab ) -> {
412
          if( newTab == null ) {
413
            // Clear the preview pane when closing an editor. When the last
414
            // tab is closed, this ensures that the preview pane is empty.
415
            getPreviewPane().clear();
416
          }
417
          else {
418
            final var tab = (FileEditorTab) newTab;
419
            updateVariableNameInjector( tab );
420
            process( tab );
421
          }
422
        }
423
    );
424
  }
425
426
  /**
427
   * Reloads the preferences from the previous session.
428
   */
429
  private void initPreferences() {
430
    initDefinitionPane();
431
    getFileEditorPane().initPreferences();
432
    getUserPreferences().addSaveEventHandler( mRPreferencesListener );
433
  }
434
435
  private void initVariableNameInjector() {
436
    updateVariableNameInjector( getActiveFileEditorTab() );
437
  }
438
439
  /**
440
   * Calls the listener when the given node is shown for the first time. The
441
   * visible property is not the same as the initial showing event; visibility
442
   * can be triggered numerous times (such as going off screen).
443
   * <p>
444
   * This is called, for example, before the drag handler can be attached,
445
   * because the scrollbar for the text editor pane must be visible.
446
   * </p>
447
   *
448
   * @param node     The node to watch for showing.
449
   * @param consumer The consumer to invoke when the event fires.
450
   */
451
  private void addShowListener(
452
      final Node node, final Consumer<Void> consumer ) {
453
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
454
        runLater( () -> {
455
          if( newShow != null && newShow ) {
456
            try {
457
              consumer.accept( null );
458
            } catch( final Exception ex ) {
459
              alert( ex );
460
            }
461
          }
462
        } );
463
464
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
465
       .flatMap( Window::showingProperty )
466
       .addListener( listener );
467
  }
468
469
  private void scrollToParagraph( final int id ) {
470
    scrollToParagraph( id, false );
471
  }
472
473
  /**
474
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
475
   *              exist.
476
   * @param force {@code true} means to force scrolling immediately, which
477
   *              should only be attempted when it is known that the document
478
   *              has been fully rendered. Otherwise the internal map of ID
479
   *              attributes will be incomplete and scrolling will flounder.
480
   */
481
  private void scrollToParagraph( final int id, final boolean force ) {
482
    synchronized( mMutex ) {
483
      final var previewPane = getPreviewPane();
484
      final var scrollPane = previewPane.getScrollPane();
485
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
486
487
      if( force ) {
488
        previewPane.scrollTo( approxId );
489
      }
490
      else {
491
        previewPane.tryScrollTo( approxId );
492
      }
493
494
      scrollPane.repaint();
495
    }
496
  }
497
498
  private void updateVariableNameInjector( final FileEditorTab tab ) {
499
    getDefinitionNameInjector().addListener( tab );
500
  }
501
502
  /**
503
   * Called whenever the preview pane becomes out of sync with the file editor
504
   * tab. This can be called when the text changes, the caret paragraph
505
   * changes, or the file tab changes.
506
   *
507
   * @param tab The file editor tab that has been changed in some fashion.
508
   */
509
  private void process( final FileEditorTab tab ) {
510
    if( tab != null ) {
511
      getPreviewPane().setPath( tab.getPath() );
512
513
      final Processor<String> processor = getProcessors().computeIfAbsent(
514
          tab, p -> createProcessors( tab )
515
      );
516
517
      try {
518
        processChain( processor, tab.getEditorText() );
519
      } catch( final Exception ex ) {
520
        alert( ex );
521
      }
522
    }
523
  }
524
525
  /**
526
   * Executes the processing chain, operating on the given string.
527
   *
528
   * @param handler The first processor in the chain to call.
529
   * @param text    The initial value of the text to process.
530
   * @return The final value of the text that was processed by the chain.
531
   */
532
  private String processChain( Processor<String> handler, String text ) {
533
    while( handler != null && text != null ) {
534
      text = handler.apply( text );
535
      handler = handler.next();
536
    }
537
538
    return text;
539
  }
540
541
  private void renderActiveTab() {
542
    process( getActiveFileEditorTab() );
543
  }
544
545
  /**
546
   * Called when a definition source is opened.
547
   *
548
   * @param path Path to the definition source that was opened.
549
   */
550
  private void openDefinitions( final Path path ) {
551
    try {
552
      final var ds = createDefinitionSource( path );
553
      setDefinitionSource( ds );
554
555
      final var prefs = getUserPreferences();
556
      prefs.definitionPathProperty().setValue( path.toFile() );
557
      prefs.save();
558
559
      final var tooltipPath = new Tooltip( path.toString() );
560
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
561
562
      final var pane = getDefinitionPane();
563
      pane.update( ds );
564
      pane.addTreeChangeHandler( mTreeHandler );
565
      pane.addKeyEventHandler( mDefinitionKeyHandler );
566
      pane.filenameProperty().setValue( path.getFileName().toString() );
567
      pane.setTooltip( tooltipPath );
568
569
      interpolateResolvedMap();
570
    } catch( final Exception ex ) {
571
      alert( ex );
572
    }
573
  }
574
575
  private void exportDefinitions( final Path path ) {
576
    try {
577
      final var pane = getDefinitionPane();
578
      final var root = pane.getTreeView().getRoot();
579
      final var problemChild = pane.isTreeWellFormed();
580
581
      if( problemChild == null ) {
582
        getDefinitionSource().getTreeAdapter().export( root, path );
583
      }
584
      else {
585
        alert( "yaml.error.tree.form", problemChild.getValue() );
586
      }
587
    } catch( final Exception ex ) {
588
      alert( ex );
589
    }
590
  }
591
592
  private void interpolateResolvedMap() {
593
    final var treeMap = getDefinitionPane().toMap();
594
    final var map = new HashMap<>( treeMap );
595
    MapInterpolator.interpolate( map );
596
597
    getResolvedMap().clear();
598
    getResolvedMap().putAll( map );
599
  }
600
601
  private void initDefinitionPane() {
602
    openDefinitions( getDefinitionPath() );
603
  }
604
605
  //---- File actions -------------------------------------------------------
606
607
  /**
608
   * Called when an {@link Observable} instance has changed. This is called
609
   * by both the {@link Snitch} service and the notify service. The @link
610
   * Snitch} service can be called for different file types, including
611
   * {@link DefinitionSource} instances.
612
   *
613
   * @param observable The observed instance.
614
   * @param value      The noteworthy item.
615
   */
616
  @Override
617
  public void update( final Observable observable, final Object value ) {
618
    if( value instanceof Path && observable instanceof Snitch ) {
619
      updateSelectedTab();
620
    }
621
  }
622
623
  /**
624
   * Called when a file has been modified.
625
   */
626
  private void updateSelectedTab() {
627
    rerender();
628
  }
629
630
  /**
631
   * After resetting the processors, they will refresh anew to be up-to-date
632
   * with the files (text and definition) currently loaded into the editor.
633
   */
634
  private void resetProcessors() {
635
    getProcessors().clear();
636
  }
637
638
  //---- File actions -------------------------------------------------------
639
640
  private void fileNew() {
641
    getFileEditorPane().newEditor();
642
  }
643
644
  private void fileOpen() {
645
    getFileEditorPane().openFileDialog();
646
  }
647
648
  private void fileClose() {
649
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
650
  }
651
652
  /**
653
   * TODO: Upon closing, first remove the tab change listeners. (There's no
654
   * need to re-render each tab when all are being closed.)
655
   */
656
  private void fileCloseAll() {
657
    getFileEditorPane().closeAllEditors();
658
  }
659
660
  private void fileSave() {
661
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
662
  }
663
664
  private void fileSaveAs() {
665
    final FileEditorTab editor = getActiveFileEditorTab();
666
    getFileEditorPane().saveEditorAs( editor );
667
    getProcessors().remove( editor );
668
669
    try {
670
      process( editor );
671
    } catch( final Exception ex ) {
672
      alert( ex );
673
    }
674
  }
675
676
  private void fileSaveAll() {
677
    getFileEditorPane().saveAllEditors();
678
  }
679
680
  private void fileExit() {
681
    final Window window = getWindow();
682
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
683
  }
684
685
  //---- Edit actions -------------------------------------------------------
686
687
  /**
688
   * Transform the Markdown into HTML then copy that HTML into the copy
689
   * buffer.
690
   */
691
  private void copyHtml() {
692
    final var markdown = getActiveEditorPane().getText();
693
    final var processors = createProcessorFactory().createProcessors(
694
        getActiveFileEditorTab()
695
    );
696
697
    final var chain = processors.remove( HtmlPreviewProcessor.class );
698
699
    final String html = processChain( chain, markdown );
700
701
    final Clipboard clipboard = Clipboard.getSystemClipboard();
702
    final ClipboardContent content = new ClipboardContent();
703
    content.putString( html );
704
    clipboard.setContent( content );
705
  }
706
707
  /**
708
   * Used to find text in the active file editor window.
709
   */
710
  private void editFind() {
711
    final TextField input = getFindTextField();
712
    getStatusBar().setGraphic( input );
713
    input.requestFocus();
714
  }
715
716
  public void editFindNext() {
717
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
718
  }
719
720
  public void editPreferences() {
721
    getUserPreferences().show();
722
  }
723
724
  //---- Insert actions -----------------------------------------------------
725
726
  /**
727
   * Delegates to the active editor to handle wrapping the current text
728
   * selection with leading and trailing strings.
729
   *
730
   * @param leading  The string to put before the selection.
731
   * @param trailing The string to put after the selection.
732
   */
733
  private void insertMarkdown(
734
      final String leading, final String trailing ) {
735
    getActiveEditorPane().surroundSelection( leading, trailing );
736
  }
737
738
  private void insertMarkdown(
739
      final String leading, final String trailing, final String hint ) {
740
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
741
  }
742
743
  //---- View actions -------------------------------------------------------
744
745
  private void viewRefresh() {
746
    rerender();
747
  }
748
749
  //---- Help actions -------------------------------------------------------
750
751
  private void helpAbout() {
752
    final Alert alert = new Alert( AlertType.INFORMATION );
753
    alert.setTitle( get( "Dialog.about.title" ) );
754
    alert.setHeaderText( get( "Dialog.about.header" ) );
755
    alert.setContentText( get( "Dialog.about.content" ) );
756
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
757
    alert.initOwner( getWindow() );
758
759
    alert.showAndWait();
760
  }
761
762
  //---- Member creators ----------------------------------------------------
763
764
  private SpellChecker createSpellChecker() {
765
    try {
766
      final Collection<String> lexicon = readLexicon( "en.txt" );
767
      return SymSpellSpeller.forLexicon( lexicon );
768
    } catch( final Exception ex ) {
769
      alert( ex );
770
      return new PermissiveSpeller();
771
    }
772
  }
773
774
  /**
775
   * Factory to create processors that are suited to different file types.
776
   *
777
   * @param tab The tab that is subjected to processing.
778
   * @return A processor suited to the file type specified by the tab's path.
779
   */
780
  private Processor<String> createProcessors( final FileEditorTab tab ) {
781
    return createProcessorFactory().createProcessors( tab );
782
  }
783
784
  private ProcessorFactory createProcessorFactory() {
785
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
786
  }
787
788
  private DefinitionPane createDefinitionPane() {
789
    return new DefinitionPane();
790
  }
791
792
  private HTMLPreviewPane createHTMLPreviewPane() {
793
    return new HTMLPreviewPane();
794
  }
795
796
  private DefinitionSource createDefaultDefinitionSource() {
797
    return new YamlDefinitionSource( getDefinitionPath() );
798
  }
799
800
  private DefinitionSource createDefinitionSource( final Path path ) {
801
    try {
802
      return createDefinitionFactory().createDefinitionSource( path );
803
    } catch( final Exception ex ) {
804
      alert( ex );
805
      return createDefaultDefinitionSource();
806
    }
807
  }
808
809
  private TextField createFindTextField() {
810
    return new TextField();
811
  }
812
813
  private DefinitionFactory createDefinitionFactory() {
814
    return new DefinitionFactory();
815
  }
816
817
  private StatusBar createStatusBar() {
818
    return new StatusBar();
819
  }
820
821
  private Scene createScene() {
822
    final SplitPane splitPane = new SplitPane(
823
        getDefinitionPane(),
824
        getFileEditorPane(),
825
        getPreviewPane() );
826
827
    splitPane.setDividerPositions(
828
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
829
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
830
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
831
832
    getDefinitionPane().prefHeightProperty()
833
                       .bind( splitPane.heightProperty() );
834
835
    final BorderPane borderPane = new BorderPane();
836
    borderPane.setPrefSize( 1280, 800 );
837
    borderPane.setTop( createMenuBar() );
838
    borderPane.setBottom( getStatusBar() );
839
    borderPane.setCenter( splitPane );
840
841
    final VBox statusBar = new VBox();
842
    statusBar.setAlignment( Pos.BASELINE_CENTER );
843
    statusBar.getChildren().add( getLineNumberText() );
844
    getStatusBar().getRightItems().add( statusBar );
845
846
    // Force preview pane refresh on Windows.
847
    if( SystemUtils.IS_OS_WINDOWS ) {
848
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
849
          ( l, oValue, nValue ) -> runLater(
850
              () -> getPreviewPane().getScrollPane().repaint()
851
          )
852
      );
853
    }
854
855
    return new Scene( borderPane );
856
  }
857
858
  private Text createLineNumberText() {
859
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
860
  }
861
862
  private Node createMenuBar() {
863
    final BooleanBinding activeFileEditorIsNull =
864
        getFileEditorPane().activeFileEditorProperty().isNull();
865
866
    // File actions
867
    final Action fileNewAction = new ActionBuilder()
868
        .setText( "Main.menu.file.new" )
869
        .setAccelerator( "Shortcut+N" )
870
        .setIcon( FILE_ALT )
871
        .setAction( e -> fileNew() )
872
        .build();
873
    final Action fileOpenAction = new ActionBuilder()
874
        .setText( "Main.menu.file.open" )
875
        .setAccelerator( "Shortcut+O" )
876
        .setIcon( FOLDER_OPEN_ALT )
877
        .setAction( e -> fileOpen() )
878
        .build();
879
    final Action fileCloseAction = new ActionBuilder()
880
        .setText( "Main.menu.file.close" )
881
        .setAccelerator( "Shortcut+W" )
882
        .setAction( e -> fileClose() )
883
        .setDisable( activeFileEditorIsNull )
884
        .build();
885
    final Action fileCloseAllAction = new ActionBuilder()
886
        .setText( "Main.menu.file.close_all" )
887
        .setAction( e -> fileCloseAll() )
888
        .setDisable( activeFileEditorIsNull )
889
        .build();
890
    final Action fileSaveAction = new ActionBuilder()
891
        .setText( "Main.menu.file.save" )
892
        .setAccelerator( "Shortcut+S" )
893
        .setIcon( FLOPPY_ALT )
894
        .setAction( e -> fileSave() )
895
        .setDisable( createActiveBooleanProperty(
896
            FileEditorTab::modifiedProperty ).not() )
897
        .build();
898
    final Action fileSaveAsAction = new ActionBuilder()
899
        .setText( "Main.menu.file.save_as" )
900
        .setAction( e -> fileSaveAs() )
901
        .setDisable( activeFileEditorIsNull )
902
        .build();
903
    final Action fileSaveAllAction = new ActionBuilder()
904
        .setText( "Main.menu.file.save_all" )
905
        .setAccelerator( "Shortcut+Shift+S" )
906
        .setAction( e -> fileSaveAll() )
907
        .setDisable( Bindings.not(
908
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
909
        .build();
910
    final Action fileExitAction = new ActionBuilder()
911
        .setText( "Main.menu.file.exit" )
912
        .setAction( e -> fileExit() )
913
        .build();
914
915
    // Edit actions
916
    final Action editCopyHtmlAction = new ActionBuilder()
917
        .setText( "Main.menu.edit.copy.html" )
918
        .setIcon( HTML5 )
919
        .setAction( e -> copyHtml() )
920
        .setDisable( activeFileEditorIsNull )
921
        .build();
922
923
    final Action editUndoAction = new ActionBuilder()
924
        .setText( "Main.menu.edit.undo" )
925
        .setAccelerator( "Shortcut+Z" )
926
        .setIcon( UNDO )
927
        .setAction( e -> getActiveEditorPane().undo() )
928
        .setDisable( createActiveBooleanProperty(
929
            FileEditorTab::canUndoProperty ).not() )
930
        .build();
931
    final Action editRedoAction = new ActionBuilder()
932
        .setText( "Main.menu.edit.redo" )
933
        .setAccelerator( "Shortcut+Y" )
934
        .setIcon( REPEAT )
935
        .setAction( e -> getActiveEditorPane().redo() )
936
        .setDisable( createActiveBooleanProperty(
937
            FileEditorTab::canRedoProperty ).not() )
938
        .build();
939
940
    final Action editCutAction = new ActionBuilder()
941
        .setText( "Main.menu.edit.cut" )
942
        .setAccelerator( "Shortcut+X" )
943
        .setIcon( CUT )
944
        .setAction( e -> getActiveEditorPane().cut() )
945
        .setDisable( activeFileEditorIsNull )
946
        .build();
947
    final Action editCopyAction = new ActionBuilder()
948
        .setText( "Main.menu.edit.copy" )
949
        .setAccelerator( "Shortcut+C" )
950
        .setIcon( COPY )
951
        .setAction( e -> getActiveEditorPane().copy() )
952
        .setDisable( activeFileEditorIsNull )
953
        .build();
954
    final Action editPasteAction = new ActionBuilder()
955
        .setText( "Main.menu.edit.paste" )
956
        .setAccelerator( "Shortcut+V" )
957
        .setIcon( PASTE )
958
        .setAction( e -> getActiveEditorPane().paste() )
959
        .setDisable( activeFileEditorIsNull )
960
        .build();
961
    final Action editSelectAllAction = new ActionBuilder()
962
        .setText( "Main.menu.edit.selectAll" )
963
        .setAccelerator( "Shortcut+A" )
964
        .setAction( e -> getActiveEditorPane().selectAll() )
965
        .setDisable( activeFileEditorIsNull )
966
        .build();
967
968
    final Action editFindAction = new ActionBuilder()
969
        .setText( "Main.menu.edit.find" )
970
        .setAccelerator( "Ctrl+F" )
971
        .setIcon( SEARCH )
972
        .setAction( e -> editFind() )
973
        .setDisable( activeFileEditorIsNull )
974
        .build();
975
    final Action editFindNextAction = new ActionBuilder()
976
        .setText( "Main.menu.edit.find.next" )
977
        .setAccelerator( "F3" )
978
        .setIcon( null )
979
        .setAction( e -> editFindNext() )
980
        .setDisable( activeFileEditorIsNull )
981
        .build();
982
    final Action editPreferencesAction = new ActionBuilder()
983
        .setText( "Main.menu.edit.preferences" )
984
        .setAccelerator( "Ctrl+Alt+S" )
985
        .setAction( e -> editPreferences() )
986
        .build();
987
988
    // Format actions
989
    final Action formatBoldAction = new ActionBuilder()
990
        .setText( "Main.menu.format.bold" )
991
        .setAccelerator( "Shortcut+B" )
992
        .setIcon( BOLD )
993
        .setAction( e -> insertMarkdown( "**", "**" ) )
994
        .setDisable( activeFileEditorIsNull )
995
        .build();
996
    final Action formatItalicAction = new ActionBuilder()
997
        .setText( "Main.menu.format.italic" )
998
        .setAccelerator( "Shortcut+I" )
999
        .setIcon( ITALIC )
1000
        .setAction( e -> insertMarkdown( "*", "*" ) )
1001
        .setDisable( activeFileEditorIsNull )
1002
        .build();
1003
    final Action formatSuperscriptAction = new ActionBuilder()
1004
        .setText( "Main.menu.format.superscript" )
1005
        .setAccelerator( "Shortcut+[" )
1006
        .setIcon( SUPERSCRIPT )
1007
        .setAction( e -> insertMarkdown( "^", "^" ) )
1008
        .setDisable( activeFileEditorIsNull )
1009
        .build();
1010
    final Action formatSubscriptAction = new ActionBuilder()
1011
        .setText( "Main.menu.format.subscript" )
1012
        .setAccelerator( "Shortcut+]" )
1013
        .setIcon( SUBSCRIPT )
1014
        .setAction( e -> insertMarkdown( "~", "~" ) )
1015
        .setDisable( activeFileEditorIsNull )
1016
        .build();
1017
    final Action formatStrikethroughAction = new ActionBuilder()
1018
        .setText( "Main.menu.format.strikethrough" )
1019
        .setAccelerator( "Shortcut+T" )
1020
        .setIcon( STRIKETHROUGH )
1021
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1022
        .setDisable( activeFileEditorIsNull )
1023
        .build();
1024
1025
    // Insert actions
1026
    final Action insertBlockquoteAction = new ActionBuilder()
1027
        .setText( "Main.menu.insert.blockquote" )
1028
        .setAccelerator( "Ctrl+Q" )
1029
        .setIcon( QUOTE_LEFT )
1030
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1031
        .setDisable( activeFileEditorIsNull )
1032
        .build();
1033
    final Action insertCodeAction = new ActionBuilder()
1034
        .setText( "Main.menu.insert.code" )
1035
        .setAccelerator( "Shortcut+K" )
1036
        .setIcon( CODE )
1037
        .setAction( e -> insertMarkdown( "`", "`" ) )
1038
        .setDisable( activeFileEditorIsNull )
1039
        .build();
1040
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1041
        .setText( "Main.menu.insert.fenced_code_block" )
1042
        .setAccelerator( "Shortcut+Shift+K" )
1043
        .setIcon( FILE_CODE_ALT )
1044
        .setAction( e -> insertMarkdown(
1045
            "\n\n```\n",
1046
            "\n```\n\n",
1047
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1048
        .setDisable( activeFileEditorIsNull )
1049
        .build();
1050
    final Action insertLinkAction = new ActionBuilder()
1051
        .setText( "Main.menu.insert.link" )
1052
        .setAccelerator( "Shortcut+L" )
1053
        .setIcon( LINK )
1054
        .setAction( e -> getActiveEditorPane().insertLink() )
1055
        .setDisable( activeFileEditorIsNull )
1056
        .build();
1057
    final Action insertImageAction = new ActionBuilder()
1058
        .setText( "Main.menu.insert.image" )
1059
        .setAccelerator( "Shortcut+G" )
1060
        .setIcon( PICTURE_ALT )
1061
        .setAction( e -> getActiveEditorPane().insertImage() )
1062
        .setDisable( activeFileEditorIsNull )
1063
        .build();
1064
1065
    // Number of heading actions (H1 ... H3)
1066
    final int HEADINGS = 3;
1067
    final Action[] headings = new Action[ HEADINGS ];
1068
1069
    for( int i = 1; i <= HEADINGS; i++ ) {
1070
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1071
      final String markup = String.format( "%n%n%s ", hashes );
1072
      final String text = "Main.menu.insert.heading." + i;
1073
      final String accelerator = "Shortcut+" + i;
1074
      final String prompt = text + ".prompt";
1075
1076
      headings[ i - 1 ] = new ActionBuilder()
1077
          .setText( text )
1078
          .setAccelerator( accelerator )
1079
          .setIcon( HEADER )
1080
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1081
          .setDisable( activeFileEditorIsNull )
1082
          .build();
1083
    }
1084
1085
    final Action insertUnorderedListAction = new ActionBuilder()
1086
        .setText( "Main.menu.insert.unordered_list" )
1087
        .setAccelerator( "Shortcut+U" )
1088
        .setIcon( LIST_UL )
1089
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1090
        .setDisable( activeFileEditorIsNull )
1091
        .build();
1092
    final Action insertOrderedListAction = new ActionBuilder()
1093
        .setText( "Main.menu.insert.ordered_list" )
1094
        .setAccelerator( "Shortcut+Shift+O" )
1095
        .setIcon( LIST_OL )
1096
        .setAction( e -> insertMarkdown(
1097
            "\n\n1. ", "" ) )
1098
        .setDisable( activeFileEditorIsNull )
1099
        .build();
1100
    final Action insertHorizontalRuleAction = new ActionBuilder()
1101
        .setText( "Main.menu.insert.horizontal_rule" )
1102
        .setAccelerator( "Shortcut+H" )
1103
        .setAction( e -> insertMarkdown(
1104
            "\n\n---\n\n", "" ) )
1105
        .setDisable( activeFileEditorIsNull )
1106
        .build();
1107
1108
    // Definition actions
1109
    final Action definitionCreateAction = new ActionBuilder()
1110
        .setText( "Main.menu.definition.create" )
1111
        .setIcon( TREE )
1112
        .setAction( e -> getDefinitionPane().addItem() )
1113
        .build();
1114
    final Action definitionInsertAction = new ActionBuilder()
1115
        .setText( "Main.menu.definition.insert" )
1116
        .setAccelerator( "Ctrl+Space" )
1117
        .setIcon( STAR )
1118
        .setAction( e -> definitionInsert() )
1119
        .build();
1120
1121
    // Help actions
1122
    final Action helpAboutAction = new ActionBuilder()
1123
        .setText( "Main.menu.help.about" )
1124
        .setAction( e -> helpAbout() )
1125
        .build();
1126
1127
    //---- MenuBar ----
1128
1129
    // File Menu
1130
    final var fileMenu = ActionUtils.createMenu(
1131
        get( "Main.menu.file" ),
1132
        fileNewAction,
1133
        fileOpenAction,
1134
        null,
1135
        fileCloseAction,
1136
        fileCloseAllAction,
1137
        null,
1138
        fileSaveAction,
1139
        fileSaveAsAction,
1140
        fileSaveAllAction,
1141
        null,
1142
        fileExitAction );
1143
1144
    // Edit Menu
1145
    final var editMenu = ActionUtils.createMenu(
1146
        get( "Main.menu.edit" ),
1147
        editCopyHtmlAction,
1148
        null,
1149
        editUndoAction,
1150
        editRedoAction,
1151
        null,
1152
        editCutAction,
1153
        editCopyAction,
1154
        editPasteAction,
1155
        editSelectAllAction,
1156
        null,
1157
        editFindAction,
1158
        editFindNextAction,
1159
        null,
1160
        editPreferencesAction );
1161
1162
    // Format Menu
1163
    final var formatMenu = ActionUtils.createMenu(
1164
        get( "Main.menu.format" ),
1165
        formatBoldAction,
1166
        formatItalicAction,
1167
        formatSuperscriptAction,
1168
        formatSubscriptAction,
1169
        formatStrikethroughAction
1170
    );
1171
1172
    // Insert Menu
1173
    final var insertMenu = ActionUtils.createMenu(
1174
        get( "Main.menu.insert" ),
1175
        insertBlockquoteAction,
1176
        insertCodeAction,
1177
        insertFencedCodeBlockAction,
1178
        null,
1179
        insertLinkAction,
1180
        insertImageAction,
1181
        null,
1182
        headings[ 0 ],
1183
        headings[ 1 ],
1184
        headings[ 2 ],
1185
        null,
1186
        insertUnorderedListAction,
1187
        insertOrderedListAction,
1188
        insertHorizontalRuleAction
1189
    );
1190
1191
    // Definition Menu
1192
    final var definitionMenu = ActionUtils.createMenu(
1193
        get( "Main.menu.definition" ),
1194
        definitionCreateAction,
1195
        definitionInsertAction );
1196
1197
    // Help Menu
1198
    final var helpMenu = ActionUtils.createMenu(
1199
        get( "Main.menu.help" ),
1200
        helpAboutAction );
1201
1202
    //---- MenuBar ----
1203
    final var menuBar = new MenuBar(
1204
        fileMenu,
1205
        editMenu,
1206
        formatMenu,
1207
        insertMenu,
1208
        definitionMenu,
1209
        helpMenu );
1210
1211
    //---- ToolBar ----
1212
    final var toolBar = ActionUtils.createToolBar(
1213
        fileNewAction,
1214
        fileOpenAction,
1215
        fileSaveAction,
1216
        null,
1217
        editUndoAction,
1218
        editRedoAction,
1219
        editCutAction,
1220
        editCopyAction,
1221
        editPasteAction,
1222
        null,
1223
        formatBoldAction,
1224
        formatItalicAction,
1225
        formatSuperscriptAction,
1226
        formatSubscriptAction,
1227
        insertBlockquoteAction,
1228
        insertCodeAction,
1229
        insertFencedCodeBlockAction,
1230
        null,
1231
        insertLinkAction,
1232
        insertImageAction,
1233
        null,
1234
        headings[ 0 ],
1235
        null,
1236
        insertUnorderedListAction,
1237
        insertOrderedListAction );
1238
1239
    return new VBox( menuBar, toolBar );
1240
  }
1241
1242
  /**
1243
   * Performs the autoinsert function on the active file editor.
1244
   */
1245
  private void definitionInsert() {
1246
    getDefinitionNameInjector().autoinsert();
1247
  }
1248
1249
  /**
1250
   * Creates a boolean property that is bound to another boolean value of the
1251
   * active editor.
1252
   */
1253
  private BooleanProperty createActiveBooleanProperty(
1254
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1255
1256
    final BooleanProperty b = new SimpleBooleanProperty();
1257
    final FileEditorTab tab = getActiveFileEditorTab();
1258
1259
    if( tab != null ) {
1260
      b.bind( func.apply( tab ) );
1261
    }
1262
1263
    getFileEditorPane().activeFileEditorProperty().addListener(
1264
        ( observable, oldFileEditor, newFileEditor ) -> {
1265
          b.unbind();
1266
1267
          if( newFileEditor == null ) {
1268
            b.set( false );
1269
          }
1270
          else {
1271
            b.bind( func.apply( newFileEditor ) );
1272
          }
1273
        }
1274
    );
1275
1276
    return b;
1277
  }
1278
1279
  //---- Convenience accessors ----------------------------------------------
1280
1281
  private Preferences getPreferences() {
1282
    return sOptions.getState();
1283
  }
1284
1285
  private int getCurrentParagraphIndex() {
1286
    return getActiveEditorPane().getCurrentParagraphIndex();
1287
  }
1288
1289
  private float getFloat( final String key, final float defaultValue ) {
1290
    return getPreferences().getFloat( key, defaultValue );
1291
  }
1292
1293
  public Window getWindow() {
1294
    return getScene().getWindow();
1295
  }
1296
1297
  private MarkdownEditorPane getActiveEditorPane() {
1298
    return getActiveFileEditorTab().getEditorPane();
1299
  }
1300
1301
  private FileEditorTab getActiveFileEditorTab() {
1302
    return getFileEditorPane().getActiveFileEditor();
1303
  }
1304
1305
  //---- Member accessors ---------------------------------------------------
1306
1307
  protected Scene getScene() {
1308
    return mScene;
1309
  }
1310
1311
  private SpellChecker getSpellChecker() {
1312
    return mSpellChecker;
1313
  }
1314
1315
  private Map<FileEditorTab, Processor<String>> getProcessors() {
1316
    return mProcessors;
1317
  }
1318
1319
  private FileEditorTabPane getFileEditorPane() {
1320
    return mFileEditorPane;
1321
  }
1322
1323
  private HTMLPreviewPane getPreviewPane() {
1324
    return mPreviewPane;
1325
  }
1326
1327
  private void setDefinitionSource(
1328
      final DefinitionSource definitionSource ) {
1329
    assert definitionSource != null;
1330
    mDefinitionSource = definitionSource;
1331
  }
1332
1333
  private DefinitionSource getDefinitionSource() {
1334
    return mDefinitionSource;
1335
  }
1336
1337
  private DefinitionPane getDefinitionPane() {
1338
    return mDefinitionPane;
1339
  }
1340
1341
  private Text getLineNumberText() {
1342
    return mLineNumberText;
1343
  }
1344
1345
  private StatusBar getStatusBar() {
1346
    return mStatusBar;
1347
  }
1348
1349
  private TextField getFindTextField() {
1350
    return mFindTextField;
1351
  }
1352
1353
  private DefinitionNameInjector getDefinitionNameInjector() {
1354
    return mDefinitionNameInjector;
1355
  }
1356
1357
  /**
1358
   * Returns the variable map of interpolated definitions.
1359
   *
1360
   * @return A map to help dereference variables.
1361
   */
1362
  private Map<String, String> getResolvedMap() {
1363
    return mResolvedMap;
1364
  }
1365
1366
  //---- Persistence accessors ----------------------------------------------
1367
1368
  private UserPreferences getUserPreferences() {
1369
    return UserPreferences.getInstance();
1370
  }
1371
1372
  private Path getDefinitionPath() {
1373
    return getUserPreferences().getDefinitionPath();
1374
  }
1375
1376
  //---- Spelling -----------------------------------------------------------
1377
1378
  /**
1379
   * Delegates to {@link #spellcheck(StyleClassedTextArea, String, int)}.
1380
   * This is called to spell check the document, rather than a single paragraph.
1381
   *
1382
   * @param text The full document text.
1383
   */
1384
  private void spellcheck(
1385
      final StyleClassedTextArea editor, final String text ) {
1386
    spellcheck( editor, text, -1 );
1387
  }
1388
1389
  /**
1390
   * Spellchecks a subset of the entire document.
1391
   *
1392
   * @param text   Look up words for this text in the lexicon.
1393
   * @param paraId Set to -1 to apply resulting style spans to the entire
1394
   *               text.
1395
   */
1396
  private void spellcheck(
1397
      final StyleClassedTextArea editor, final String text, final int paraId ) {
1398
    final var builder = new StyleSpansBuilder<Collection<String>>();
1399
    final var runningIndex = new AtomicInteger( 0 );
1400
    final var checker = getSpellChecker();
1401
1402
    // The text nodes must be relayed through a contextual "visitor" that
1403
    // can return text in chunks with correlative offsets into the string.
1404
    // This allows Markdown, R Markdown, XML, and R XML documents to return
1405
    // sets of words to check.
1406
1407
    final var node = mParser.parse( text );
1408
    final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
1409
      // Treat hyphenated compound words as individual words.
1410
      final var check = visited.replace( '-', ' ' );
1411
1412
      checker.proofread( check, ( misspelled, prevIndex, currIndex ) -> {
1413
        prevIndex += bIndex;
1414
        currIndex += bIndex;
1415
1416
        // Clear styling between lexiconically absent words.
1417
        builder.add( emptyList(), prevIndex - runningIndex.get() );
1418
        builder.add( singleton( "spelling" ), currIndex - prevIndex );
1419
        runningIndex.set( currIndex );
1420
      } );
1421
    } );
1422
1423
    visitor.visit( node );
1424
1425
    // If the running index was set, at least one word triggered the listener.
1426
    if( runningIndex.get() > 0 ) {
1427
      // Clear styling after the last lexiconically absent word.
1428
      builder.add( emptyList(), text.length() - runningIndex.get() );
1429
1430
      final var spans = builder.create();
1431
1432
      if( paraId >= 0 ) {
1433
        editor.setStyleSpans( paraId, 0, spans );
1434
      }
1435
      else {
1436
        editor.setStyleSpans( 0, spans );
1437
      }
1438
    }
1439
  }
1440
1441
  @SuppressWarnings("SameParameterValue")
1442
  private Collection<String> readLexicon( final String filename )
1443
      throws Exception {
1444
    final var path = "/" + LEXICONS_DIRECTORY + "/" + filename;
1445
1446
    try( final var resource = getClass().getResourceAsStream( path ) ) {
1447
      if( resource == null ) {
1448
        throw new FileNotFoundException( path );
1449
      }
1450
1451
      try( final var isr = new InputStreamReader( resource, UTF_8 );
1452
           final var reader = new BufferedReader( isr ) ) {
1453
        return reader.lines().collect( Collectors.toList() );
1454
      }
14481455
    }
14491456
  }
M src/main/java/com/scrivenvar/StatusBarNotifier.java
8383
   * Called when an exception occurs that warrants the user's attention.
8484
   *
85
   * @param ex The exception with a message that the user should know about.
85
   * @param t The exception with a message that the user should know about.
8686
   */
87
  public static void alert( final Exception ex ) {
88
    update( ex.getMessage() );
87
  public static void alert( final Throwable t ) {
88
    update( t.getMessage() );
8989
  }
9090
9191
  /**
92
   * Updates the status bar to show the given message.
92
   * Updates the status bar to show the first line of the given message.
9393
   *
94
   * @param s The message to show in the status bar.
94
   * @param message The message to show in the status bar.
9595
   */
96
  private static void update( final String s ) {
96
  private static void update( final String message ) {
9797
    runLater(
9898
        () -> {
99
          final var s = message == null ? "" : message;
99100
          final var i = s.indexOf( '\n' );
100101
          sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) );
M src/main/java/com/scrivenvar/adapters/DocumentAdapter.java
3030
import org.xhtmlrenderer.event.DocumentListener;
3131
32
import static com.scrivenvar.StatusBarNotifier.alert;
33
3234
/**
3335
 * Allows subclasses to implement specific events.
...
4446
  @Override
4547
  public void onLayoutException( final Throwable t ) {
48
    alert( t );
4649
  }
4750
4851
  @Override
4952
  public void onRenderException( final Throwable t ) {
53
    alert( t );
5054
  }
5155
}
M src/main/java/com/scrivenvar/definition/DefinitionSource.java
4444
   */
4545
  TreeAdapter getTreeAdapter();
46
47
  /**
48
   * Returns the error message, if any, that occurred while loading the
49
   * definition source.
50
   *
51
   * @return The empty string if no error occurred, otherwise the error message.
52
   */
53
  default String getError() {
54
    return "";
55
  }
5646
}
5747
M src/main/java/com/scrivenvar/definition/yaml/YamlDefinitionSource.java
5555
    return mYamlTreeAdapter;
5656
  }
57
58
  @Override
59
  public String getError() {
60
    return getYamlParser().getError();
61
  }
62
63
  private YamlParser getYamlParser() {
64
    return mYamlTreeAdapter.getYamlParser();
65
  }
6657
}
6758
M src/main/java/com/scrivenvar/definition/yaml/YamlParser.java
3131
import com.fasterxml.jackson.databind.ObjectMapper;
3232
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
33
import com.scrivenvar.Messages;
3433
import com.scrivenvar.definition.DocumentParser;
3534
3635
import java.io.InputStream;
3736
import java.nio.file.Files;
3837
import java.nio.file.Path;
39
40
import static com.scrivenvar.Constants.STATUS_BAR_OK;
4138
4239
/**
4340
 * Responsible for reading a YAML document into an object hierarchy.
4441
 */
4542
public class YamlParser implements DocumentParser<JsonNode> {
46
47
  /**
48
   * Error that occurred while parsing.
49
   */
50
  private String mError;
5143
5244
  /**
...
8577
  private JsonNode parse( final Path path ) {
8678
    try( final InputStream in = Files.newInputStream( path ) ) {
87
      setError( Messages.get( STATUS_BAR_OK ) );
88
8979
      return new ObjectMapper( new YAMLFactory() ).readTree( in );
9080
    } catch( final Exception e ) {
91
      setError( Messages.get( "yaml.error.open" ) );
92
9381
      // Ensure that a document root node exists by relying on the
9482
      // default failure condition when processing. This is required
9583
      // because the input stream could not be read.
9684
      return new ObjectMapper().createObjectNode();
9785
    }
98
  }
99
100
  private void setError( final String error ) {
101
    mError = error;
102
  }
103
104
  /**
105
   * Returns the last error message, if any, that occurred during parsing.
106
   *
107
   * @return The error message or the empty string if no error occurred.
108
   */
109
  public String getError() {
110
    return mError;
11186
  }
11287
}
M src/main/java/com/scrivenvar/editors/DefinitionNameInjector.java
104104
   * substitute the definition reference.
105105
   */
106
  private void autoinsert() {
106
  public void autoinsert() {
107107
    final String paragraph = getCaretParagraph();
108108
    final int[] bounds = getWordBoundariesAtCaret();
...
149149
   * where the caret is located. There are a few different scenarios, where
150150
   * the caret can be at: the start, end, or middle of a word; also, the
151
   * caret can be at the end or beginning of a punctuated word.
151
   * caret can be at the end or beginning of a punctuated word; as well, the
152
   * caret could be at the beginning or end of the line or document.
152153
   */
153154
  private int[] getWordBoundariesAtCaret() {
M src/main/java/com/scrivenvar/editors/EditorPane.java
2828
package com.scrivenvar.editors;
2929
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.Options;
30
import com.scrivenvar.preferences.UserPreferences;
3231
import javafx.beans.property.IntegerProperty;
3332
import javafx.beans.property.ObjectProperty;
...
5554
 */
5655
public class EditorPane extends Pane {
57
58
  private static final Options sOptions = Services.load( Options.class );
5956
6057
  /**
...
247244
   */
248245
  private IntegerProperty fontsSizeProperty() {
249
    return sOptions.getUserPreferences().fontsSizeEditorProperty();
246
    return UserPreferences.getInstance().fontsSizeEditorProperty();
250247
  }
251248
}
M src/main/java/com/scrivenvar/preferences/UserPreferences.java
5050
 */
5151
public class UserPreferences {
52
  /**
53
   * Implementation of the  initialization-on-demand holder design pattern,
54
   * an for a lazy-loaded singleton. In all versions of Java, the idiom enables
55
   * a safe, highly concurrent lazy initialization of static fields with good
56
   * performance. The implementation relies upon the initialization phase of
57
   * execution within the Java Virtual Machine (JVM) as specified by the Java
58
   * Language Specification. When the class {@link UserPreferencesContainer}
59
   * is loaded, its initialization completes trivially because there are no
60
   * static variables to initialize.
61
   * <p>
62
   * The static class definition {@link UserPreferencesContainer} within the
63
   * {@link UserPreferences} is not initialized until such time that
64
   * {@link UserPreferencesContainer} must be executed. The static
65
   * {@link UserPreferencesContainer} class executes when
66
   * {@link #getInstance} is called. The first call will trigger loading and
67
   * initialization of the {@link UserPreferencesContainer} thereby
68
   * instantiating the {@link #INSTANCE}.
69
   * </p>
70
   * <p>
71
   * This indirection is necessary because the {@link UserPreferences} class
72
   * references {@link PreferencesFx}, which must not be instantiated until the
73
   * UI is ready.
74
   * </p>
75
   */
76
  private static class UserPreferencesContainer {
77
    private static final UserPreferences INSTANCE = new UserPreferences();
78
  }
79
80
  public static UserPreferences getInstance() {
81
    return UserPreferencesContainer.INSTANCE;
82
  }
83
5284
  private final PreferencesFx mPreferencesFx;
5385
...
6395
  private final IntegerProperty mPropFontsSizeEditor;
6496
65
  public UserPreferences() {
97
  private UserPreferences() {
6698
    mPropRDirectory = simpleFile( USER_DIRECTORY );
6799
    mPropRScript = new SimpleStringProperty( "" );
...
80112
    mRDelimiterEnded = new SimpleStringProperty( R_DELIM_ENDED_DEFAULT );
81113
82
    mPropFontsSizeEditor = new SimpleIntegerProperty( FONT_SIZE_EDITOR );
114
    mPropFontsSizeEditor = new SimpleIntegerProperty( (int) FONT_SIZE_EDITOR );
83115
84116
    // All properties must be initialized before creating the dialog.
...
164196
            Group.of(
165197
                get( "Preferences.definitions.delimiter.began" ),
166
                Setting.of( label( "Preferences.definitions.delimiter.began.desc" ) ),
198
                Setting.of( label(
199
                    "Preferences.definitions.delimiter.began.desc" ) ),
167200
                Setting.of( "Opening", mDefDelimiterBegan )
168201
            ),
169202
            Group.of(
170203
                get( "Preferences.definitions.delimiter.ended" ),
171
                Setting.of( label( "Preferences.definitions.delimiter.ended.desc" ) ),
204
                Setting.of( label(
205
                    "Preferences.definitions.delimiter.ended.desc" ) ),
172206
                Setting.of( "Closing", mDefDelimiterEnded )
173207
            )
M src/main/java/com/scrivenvar/preview/CustomImageLoader.java
3434
import org.xhtmlrenderer.swing.ImageResourceLoader;
3535
36
import javax.imageio.ImageIO;
3637
import java.net.URI;
37
import java.nio.file.Files;
38
import java.net.URL;
3839
import java.nio.file.Paths;
3940
41
import static com.scrivenvar.StatusBarNotifier.alert;
4042
import static com.scrivenvar.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER;
4143
import static com.scrivenvar.util.ProtocolResolver.getProtocol;
44
import static java.lang.String.valueOf;
45
import static java.nio.file.Files.exists;
4246
import static org.xhtmlrenderer.swing.AWTFSImage.createImage;
4347
...
5054
   * Placeholder that's displayed when image cannot be found.
5155
   */
52
  private static final FSImage BROKEN_IMAGE = createImage(
53
      BROKEN_IMAGE_PLACEHOLDER );
56
  private FSImage mBrokenImage;
5457
5558
  private final IntegerProperty mWidthProperty = new SimpleIntegerProperty();
...
8285
    assert width >= 0;
8386
    assert height >= 0;
84
85
    boolean exists = true;
8687
8788
    try {
8889
      final var protocol = getProtocol( uri );
90
      final ImageResource imageResource;
8991
90
      if( protocol.isFile() ) {
91
        exists = Files.exists( Paths.get( new URI( uri ) ) );
92
      if( protocol.isFile() && exists( Paths.get( new URI( uri ) ) ) ) {
93
        imageResource = super.get( uri, width, height );
94
      }
95
      else if( protocol.isHttp() ) {
96
        // FlyingSaucer will silently swallow any images that fail to load.
97
        // Consequently, the following lines load the resource over HTTP and
98
        // translate errors into a broken image icon.
99
        final var url = new URL( uri );
100
        final var image = ImageIO.read( url );
101
        imageResource = new ImageResource( uri, createImage( image ) );
102
      }
103
      else {
104
        // Caught below to return a broken image; exception is swallowed.
105
        throw new UnsupportedOperationException( valueOf( protocol ) );
92106
      }
107
108
      return scale( imageResource );
93109
    } catch( final Exception e ) {
94
      exists = false;
110
      alert( e );
111
      return new ImageResource( uri, getBrokenImage() );
95112
    }
96
97
    return exists
98
        ? scale( uri, width, height )
99
        : new ImageResource( uri, BROKEN_IMAGE );
100113
  }
101114
102115
  /**
103116
   * Scales the image found at the given URI.
104117
   *
105
   * @param uri Path to the image file to load.
106
   * @param w   Ignored.
107
   * @param h   Ignored.
118
   * @param ir {@link ImageResource} of image loaded successfully.
108119
   * @return Resource representing the rendered image and path.
109120
   */
110
  private ImageResource scale( final String uri, final int w, final int h ) {
111
    final var ir = super.get( uri, w, h );
121
  private ImageResource scale( final ImageResource ir ) {
112122
    final var image = ir.getImage();
113123
    final var imageWidth = image.getWidth();
...
126136
    image.scale( newWidth, newHeight );
127137
    return ir;
138
  }
139
140
  /**
141
   * Lazily initializes the broken image placeholder.
142
   *
143
   * @return The {@link FSImage} that represents a broken image icon.
144
   */
145
  private FSImage getBrokenImage() {
146
    final var image = mBrokenImage;
147
148
    if( image == null ) {
149
      mBrokenImage = createImage( BROKEN_IMAGE_PLACEHOLDER );
150
    }
151
152
    return mBrokenImage;
128153
  }
129154
}
M src/main/java/com/scrivenvar/preview/MathRenderer.java
2828
package com.scrivenvar.preview;
2929
30
import com.scrivenvar.preferences.UserPreferences;
3031
import com.whitemagicsoftware.tex.*;
3132
import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D;
33
import javafx.beans.property.IntegerProperty;
3234
import org.w3c.dom.Document;
35
36
import java.util.function.Supplier;
37
38
import static com.scrivenvar.StatusBarNotifier.alert;
3339
3440
/**
3541
 * Responsible for rendering formulas as scalable vector graphics (SVG).
3642
 */
3743
public class MathRenderer {
3844
39
  private static final float mSize = 20f;
45
  /**
46
   * Default font size in points.
47
   */
48
  private static final float FONT_SIZE = 20f;
4049
41
  private final TeXFont mTeXFont = new DefaultTeXFont( mSize );
42
  private final TeXEnvironment mEnvironment = new TeXEnvironment( mTeXFont );
43
  private final SvgDomGraphics2D mGraphics = new SvgDomGraphics2D();
50
  private final TeXFont mTeXFont = createDefaultTeXFont( FONT_SIZE );
51
  private final TeXEnvironment mEnvironment = createTeXEnvironment( mTeXFont );
52
  private final SvgDomGraphics2D mGraphics = createSvgDomGraphics2D();
4453
4554
  public MathRenderer() {
46
    mGraphics.scale( mSize, mSize );
55
    mGraphics.scale( FONT_SIZE, FONT_SIZE );
4756
  }
4857
...
5665
    final var formula = new TeXFormula( equation );
5766
    final var box = formula.createBox( mEnvironment );
58
    final var l = new TeXLayout( box, mSize );
67
    final var l = new TeXLayout( box, FONT_SIZE );
5968
6069
    mGraphics.initialize( l.getWidth(), l.getHeight() );
6170
    box.draw( mGraphics, l.getX(), l.getY() );
6271
    return mGraphics.toDom();
72
  }
73
74
  @SuppressWarnings("SameParameterValue")
75
  private TeXFont createDefaultTeXFont( final float fontSize ) {
76
    return create( () -> new DefaultTeXFont( fontSize ) );
77
  }
78
79
  private TeXEnvironment createTeXEnvironment( final TeXFont texFont ) {
80
    return create( () -> new TeXEnvironment( texFont ) );
81
  }
82
83
  private SvgDomGraphics2D createSvgDomGraphics2D() {
84
    return create( SvgDomGraphics2D::new );
85
  }
86
87
  /**
88
   * Tries to instantiate a given object, returning {@code null} on failure.
89
   * The failure message is bubbled up to to the user interface.
90
   *
91
   * @param supplier Creates an instance.
92
   * @param <T>      The type of instance being created.
93
   * @return An instance of the parameterized type or {@code null} upon error.
94
   */
95
  private <T> T create( final Supplier<T> supplier ) {
96
    try {
97
      return supplier.get();
98
    } catch( final Exception ex ) {
99
      alert( ex );
100
      return null;
101
    }
63102
  }
64103
}
M src/main/java/com/scrivenvar/preview/SvgReplacedElementFactory.java
4545
import static com.scrivenvar.StatusBarNotifier.alert;
4646
import static com.scrivenvar.preview.SvgRasterizer.rasterize;
47
import static com.scrivenvar.processors.markdown.tex.TeXNode.HTML_TEX;
4748
4849
/**
4950
 * Responsible for running {@link SvgRasterizer} on SVG images detected within
5051
 * a document to transform them into rasterized versions.
5152
 */
52
public class SvgReplacedElementFactory
53
    implements ReplacedElementFactory {
53
public class SvgReplacedElementFactory implements ReplacedElementFactory {
5454
5555
  /**
56
   * SVG filename extension maps to an SVG image element.
56
   * Prevent instantiation until needed.
5757
   */
58
  private static final String SVG_FILE = "svg";
58
  private static class MathRendererContainer {
59
    private static final MathRenderer INSTANCE = new MathRenderer();
60
  }
5961
6062
  /**
61
   * TeX expression wrapped in a {@code <tex>} element.
63
   * Returns the singleton instance for rendering math symbols.
64
   *
65
   * @return A non-null instance, loaded, configured, and ready to render math.
6266
   */
63
  private static final String HTML_TEX = "tex";
67
  public static MathRenderer getInstance() {
68
    return MathRendererContainer.INSTANCE;
69
  }
70
71
  /**
72
   * SVG filename extension maps to an SVG image element.
73
   */
74
  private static final String SVG_FILE = "svg";
6475
6576
  private static final String HTML_IMAGE = "img";
6677
  private static final String HTML_IMAGE_SRC = "src";
67
68
  private static final MathRenderer sMathRenderer = new MathRenderer();
6978
7079
  /**
...
100109
        }
101110
        else if( HTML_TEX.equals( nodeName ) ) {
102
          // Convert the <svg> element to a raster graphic if not yet cached.
111
          // Convert the TeX element to a raster graphic if not yet cached.
103112
          final var src = e.getTextContent();
104113
          image = getCachedImage(
105
              src, __ -> rasterize( sMathRenderer.render( src ) )
114
              src, __ -> rasterize( getInstance().render( src ) )
106115
          );
107116
        }
...
138147
   * quick to return the corresponding {@link BufferedImage}.
139148
   *
140
   * @param src        The SVG used for the key into the image cache.
141
   * @param rasterizer {@link Function} to call to convert SVG to an image.
149
   * @param src        The source used for the key into the image cache.
150
   * @param rasterizer {@link Function} to call to rasterize an image.
142151
   * @return The image that corresponds to the given source string.
143152
   */
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
2828
package com.scrivenvar.processors;
2929
30
import com.scrivenvar.Services;
3130
import com.scrivenvar.preferences.UserPreferences;
32
import com.scrivenvar.service.Options;
3331
import javafx.beans.property.ObjectProperty;
3432
import javafx.beans.property.StringProperty;
...
5351
 */
5452
public final class InlineRProcessor extends DefinitionProcessor {
55
56
  private static final Options sOptions = Services.load( Options.class );
57
5853
  /**
5954
   * Constrain memory when typing new R expressions into the document.
...
264259
265260
  private UserPreferences getUserPreferences() {
266
    return sOptions.getUserPreferences();
261
    return UserPreferences.getInstance();
267262
  }
268263
M src/main/java/com/scrivenvar/processors/markdown/ImageLinkExtension.java
2828
package com.scrivenvar.processors.markdown;
2929
30
import com.scrivenvar.Services;
3130
import com.scrivenvar.preferences.UserPreferences;
32
import com.scrivenvar.service.Options;
3331
import com.vladsch.flexmark.ast.Image;
3432
import com.vladsch.flexmark.html.IndependentLinkResolverFactory;
...
5755
 */
5856
public class ImageLinkExtension implements HtmlRendererExtension {
59
  /**
60
   * Used for image directory preferences.
61
   */
62
  private static final Options sOptions = Services.load( Options.class );
6357
6458
  /**
...
200194
201195
  private UserPreferences getUserPreferences() {
202
    return getOptions().getUserPreferences();
203
  }
204
205
  private static Options getOptions() {
206
    return sOptions;
196
    return UserPreferences.getInstance();
207197
  }
208198
}
M src/main/java/com/scrivenvar/processors/markdown/tex/TeXNode.java
3131
3232
public class TeXNode extends DelimitedNodeImpl {
33
  /**
34
   * TeX expression wrapped in a {@code <tex>} element.
35
   */
36
  public static final String HTML_TEX = "tex";
3337
3438
  public TeXNode() {
3539
  }
36
3740
}
3841
M src/main/java/com/scrivenvar/processors/markdown/tex/TeXNodeRenderer.java
3737
import org.jetbrains.annotations.Nullable;
3838
39
import java.util.HashSet;
4039
import java.util.Set;
40
41
import static com.scrivenvar.processors.markdown.tex.TeXNode.HTML_TEX;
4142
4243
public class TeXNodeRenderer implements NodeRenderer {
...
5253
  @Override
5354
  public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
54
    final Set<NodeRenderingHandler<?>> set = new HashSet<>();
55
    set.add( new NodeRenderingHandler<>(
56
        TeXNode.class, this::render ) );
57
58
    return set;
55
    return Set.of( new NodeRenderingHandler<>( TeXNode.class, this::render ) );
5956
  }
6057
6158
  private void render( final TeXNode node,
6259
                       final NodeRendererContext context,
6360
                       final HtmlWriter html ) {
64
    html.tag( "tex" );
61
    html.tag( HTML_TEX );
6562
    html.raw( node.getText() );
66
    html.closeTag( "tex" );
63
    html.closeTag( HTML_TEX );
6764
  }
6865
}
M src/main/java/com/scrivenvar/service/Options.java
2828
package com.scrivenvar.service;
2929
30
import com.scrivenvar.preferences.UserPreferences;
30
import com.dlsc.preferencesfx.PreferencesFx;
3131
3232
import java.util.prefs.BackingStoreException;
3333
import java.util.prefs.Preferences;
3434
3535
/**
36
 * Responsible for persisting options.
36
 * Responsible for persisting options that are safe to load before the UI
37
 * is shown. This can include items like window dimensions, last file
38
 * opened, split pane locations, and more. This cannot be used to persist
39
 * options that are user-controlled (i.e., all options available through
40
 * {@link PreferencesFx}).
3741
 */
3842
public interface Options extends Service {
39
40
  /**
41
   * Returns a reference to the persistent settings that may be configured
42
   * through the UI.
43
   *
44
   * @return A valid {@link UserPreferences} instance, never {@code null}.
45
   */
46
  UserPreferences getUserPreferences();
4743
4844
  /**
M src/main/java/com/scrivenvar/service/impl/DefaultOptions.java
2727
package com.scrivenvar.service.impl;
2828
29
import com.scrivenvar.preferences.UserPreferences;
3029
import com.scrivenvar.service.Options;
3130
...
4140
 */
4241
public class DefaultOptions implements Options {
43
  private final UserPreferences mPreferences = new UserPreferences();
44
4542
  public DefaultOptions() {
4643
  }
...
7875
  public Preferences getState() {
7976
    return getRootPreferences().node( PREFS_STATE );
80
  }
81
82
  @Override
83
  public UserPreferences getUserPreferences() {
84
    return mPreferences;
8577
  }
8678
}
M src/main/java/com/scrivenvar/sigils/SigilOperator.java
2828
package com.scrivenvar.sigils;
2929
30
import com.scrivenvar.Services;
3130
import com.scrivenvar.preferences.UserPreferences;
32
import com.scrivenvar.service.Options;
3331
3432
import java.util.function.UnaryOperator;
...
4139
 */
4240
public abstract class SigilOperator implements UnaryOperator<String> {
43
  private static final Options sOptions = Services.load( Options.class );
44
4541
  protected static UserPreferences getUserPreferences() {
46
    return sOptions.getUserPreferences();
42
    return UserPreferences.getInstance();
4743
  }
4844
}
M src/main/java/com/scrivenvar/util/ProtocolScheme.java
7777
    for( final var scheme : values() ) {
7878
      // This will match HTTP/HTTPS as well as FILE*, which may be inaccurate.
79
      if( scheme.name().startsWith( protocol ) ) {
79
      if( protocol.startsWith( scheme.name() ) ) {
8080
        result = scheme;
8181
        break;
M src/main/resources/com/scrivenvar/preview/webview.css
2727
p, blockquote, ul, ol, dl, table, pre {
2828
  margin: 1em 0;
29
  vertical-align: middle;
3029
}
3130
...
218217
 * See SVGReplacedElementFactory for details.
219218
 */
220
svg, tex {
219
tex {
221220
  /* Ensure the formulas can be inlined with text. */
222221
  display: inline-block;
223
  vertical-align: middle;
222
}
223
224
/* Without a robust typesetting engine, there's no
225
 * nice-looking way to automatically typeset equations.
226
 * Sometimes baseline is appropriate, sometimes the
227
 * descender must be considered, and sometimes vertical
228
 * alignment to the middle looks best.
229
 */
230
p tex {
231
  vertical-align: baseline;
224232
}
225233