Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M README.md
5454
5555
* Search and replace using variables
56
* Re-organize variable names
56
* Reorganize variable names
5757
5858
## Screenshot
5959
6060
![Screenshot](images/screenshot.png)
6161
6262
## License
6363
6464
This software is licensed under the [BSD 2-Clause License](LICENSE.md).
65
6665
M src/main/java/com/scrivenvar/MainWindow.java
4444
import com.scrivenvar.service.Snitch;
4545
import com.scrivenvar.service.events.Notifier;
46
import com.scrivenvar.spelling.api.SpellChecker;
47
import com.scrivenvar.spelling.impl.PermissiveSpeller;
48
import com.scrivenvar.spelling.impl.SymSpellSpeller;
49
import com.scrivenvar.util.Action;
50
import com.scrivenvar.util.ActionBuilder;
51
import com.scrivenvar.util.ActionUtils;
52
import javafx.beans.binding.Bindings;
53
import javafx.beans.binding.BooleanBinding;
54
import javafx.beans.property.BooleanProperty;
55
import javafx.beans.property.SimpleBooleanProperty;
56
import javafx.beans.value.ChangeListener;
57
import javafx.beans.value.ObservableBooleanValue;
58
import javafx.beans.value.ObservableValue;
59
import javafx.collections.ListChangeListener.Change;
60
import javafx.collections.ObservableList;
61
import javafx.event.Event;
62
import javafx.event.EventHandler;
63
import javafx.geometry.Pos;
64
import javafx.scene.Node;
65
import javafx.scene.Scene;
66
import javafx.scene.control.*;
67
import javafx.scene.control.Alert.AlertType;
68
import javafx.scene.image.Image;
69
import javafx.scene.image.ImageView;
70
import javafx.scene.input.Clipboard;
71
import javafx.scene.input.ClipboardContent;
72
import javafx.scene.input.KeyEvent;
73
import javafx.scene.layout.BorderPane;
74
import javafx.scene.layout.VBox;
75
import javafx.scene.text.Text;
76
import javafx.stage.Window;
77
import javafx.stage.WindowEvent;
78
import javafx.util.Duration;
79
import org.apache.commons.lang3.SystemUtils;
80
import org.controlsfx.control.StatusBar;
81
import org.fxmisc.richtext.StyleClassedTextArea;
82
import org.fxmisc.richtext.model.StyleSpansBuilder;
83
import org.reactfx.value.Val;
84
85
import java.io.BufferedReader;
86
import java.io.InputStreamReader;
87
import java.nio.file.Path;
88
import java.nio.file.Paths;
89
import java.util.*;
90
import java.util.concurrent.atomic.AtomicInteger;
91
import java.util.function.Consumer;
92
import java.util.function.Function;
93
import java.util.prefs.Preferences;
94
import java.util.stream.Collectors;
95
96
import static com.scrivenvar.Constants.*;
97
import static com.scrivenvar.Messages.get;
98
import static com.scrivenvar.util.StageState.*;
99
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
100
import static java.nio.charset.StandardCharsets.UTF_8;
101
import static java.util.Collections.emptyList;
102
import static java.util.Collections.singleton;
103
import static javafx.application.Platform.runLater;
104
import static javafx.event.Event.fireEvent;
105
import static javafx.scene.input.KeyCode.ENTER;
106
import static javafx.scene.input.KeyCode.TAB;
107
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
108
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
109
110
/**
111
 * Main window containing a tab pane in the center for file editors.
112
 */
113
public class MainWindow implements Observer {
114
  /**
115
   * The {@code OPTIONS} variable must be declared before all other variables
116
   * to prevent subsequent initializations from failing due to missing user
117
   * preferences.
118
   */
119
  private final static Options sOptions = Services.load( Options.class );
120
  private final static Snitch SNITCH = Services.load( Snitch.class );
121
  private final static Notifier sNotifier = Services.load( Notifier.class );
122
123
  private final Scene mScene;
124
  private final StatusBar mStatusBar;
125
  private final Text mLineNumberText;
126
  private final TextField mFindTextField;
127
  private final SpellChecker mSpellChecker;
128
129
  private final Object mMutex = new Object();
130
131
  /**
132
   * Prevents re-instantiation of processing classes.
133
   */
134
  private final Map<FileEditorTab, Processor<String>> mProcessors =
135
      new HashMap<>();
136
137
  private final Map<String, String> mResolvedMap =
138
      new HashMap<>( DEFAULT_MAP_SIZE );
139
140
  /**
141
   * Called when the definition data is changed.
142
   */
143
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
144
      mTreeHandler = event -> {
145
    exportDefinitions( getDefinitionPath() );
146
    interpolateResolvedMap();
147
    renderActiveTab();
148
  };
149
150
  /**
151
   * Called to switch to the definition pane when the user presses the TAB key.
152
   */
153
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
154
      (EventHandler<KeyEvent>) event -> {
155
        if( event.getCode() == TAB ) {
156
          getDefinitionPane().requestFocus();
157
          event.consume();
158
        }
159
      };
160
161
  /**
162
   * Called to inject the selected item when the user presses ENTER in the
163
   * definition pane.
164
   */
165
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
166
      event -> {
167
        if( event.getCode() == ENTER ) {
168
          getVariableNameInjector().injectSelectedItem();
169
        }
170
      };
171
172
  private final ChangeListener<Integer> mCaretPositionListener =
173
      ( observable, oldPosition, newPosition ) -> {
174
        final FileEditorTab tab = getActiveFileEditorTab();
175
        final EditorPane pane = tab.getEditorPane();
176
        final StyleClassedTextArea editor = pane.getEditor();
177
178
        getLineNumberText().setText(
179
            get( STATUS_BAR_LINE,
180
                 editor.getCurrentParagraph() + 1,
181
                 editor.getParagraphs().size(),
182
                 editor.getCaretPosition()
183
            )
184
        );
185
      };
186
187
  private final ChangeListener<Integer> mCaretParagraphListener =
188
      ( observable, oldIndex, newIndex ) ->
189
          scrollToParagraph( newIndex, true );
190
191
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
192
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
193
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
194
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
195
      mCaretPositionListener,
196
      mCaretParagraphListener );
197
198
  /**
199
   * Listens on the definition pane for double-click events.
200
   */
201
  private final VariableNameInjector mVariableNameInjector
202
      = new VariableNameInjector( mDefinitionPane );
203
204
  public MainWindow() {
205
    sNotifier.addObserver( this );
206
207
    mStatusBar = createStatusBar();
208
    mLineNumberText = createLineNumberText();
209
    mFindTextField = createFindTextField();
210
    mScene = createScene();
211
    mSpellChecker = createSpellChecker();
212
213
    // Add the close request listener before the window is shown.
214
    initLayout();
215
  }
216
217
  /**
218
   * Called after the stage is shown.
219
   */
220
  public void init() {
221
    initFindInput();
222
    initSnitch();
223
    initDefinitionListener();
224
    initTabAddedListener();
225
    initTabChangedListener();
226
    initPreferences();
227
    initVariableNameInjector();
228
  }
229
230
  private void initLayout() {
231
    final Scene appScene = getScene();
232
233
    appScene.getStylesheets().add( STYLESHEET_SCENE );
234
235
    // TODO: Apply an XML syntax highlighting for XML files.
236
//    appScene.getStylesheets().add( STYLESHEET_XML );
237
    appScene.windowProperty().addListener(
238
        ( observable, oldWindow, newWindow ) ->
239
            newWindow.setOnCloseRequest(
240
                e -> {
241
                  if( !getFileEditorPane().closeAllEditors() ) {
242
                    e.consume();
243
                  }
244
                }
245
            )
246
    );
247
  }
248
249
  /**
250
   * Initialize the find input text field to listen on F3, ENTER, and
251
   * ESCAPE key presses.
252
   */
253
  private void initFindInput() {
254
    final TextField input = getFindTextField();
255
256
    input.setOnKeyPressed( ( KeyEvent event ) -> {
257
      switch( event.getCode() ) {
258
        case F3:
259
        case ENTER:
260
          editFindNext();
261
          break;
262
        case F:
263
          if( !event.isControlDown() ) {
264
            break;
265
          }
266
        case ESCAPE:
267
          getStatusBar().setGraphic( null );
268
          getActiveFileEditorTab().getEditorPane().requestFocus();
269
          break;
270
      }
271
    } );
272
273
    // Remove when the input field loses focus.
274
    input.focusedProperty().addListener(
275
        ( focused, oldFocus, newFocus ) -> {
276
          if( !newFocus ) {
277
            getStatusBar().setGraphic( null );
278
          }
279
        }
280
    );
281
  }
282
283
  /**
284
   * Watch for changes to external files. In particular, this awaits
285
   * modifications to any XSL files associated with XML files being edited.
286
   * When
287
   * an XSL file is modified (external to the application), the snitch's ears
288
   * perk up and the file is reloaded. This keeps the XSL transformation up to
289
   * date with what's on the file system.
290
   */
291
  private void initSnitch() {
292
    SNITCH.addObserver( this );
293
  }
294
295
  /**
296
   * Listen for {@link FileEditorTabPane} to receive open definition file
297
   * event.
298
   */
299
  private void initDefinitionListener() {
300
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
301
        ( final ObservableValue<? extends Path> file,
302
          final Path oldPath, final Path newPath ) -> {
303
          // Indirectly refresh the resolved map.
304
          resetProcessors();
305
306
          openDefinitions( newPath );
307
308
          // Will create new processors and therefore a new resolved map.
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
                initTabKeyEventListener( tab );
335
                initScrollEventListener( tab );
336
                initSpellCheckListener( tab );
337
//              initSyntaxListener( tab );
338
              }
339
            }
340
          }
341
        }
342
    );
343
  }
344
345
  private void initTextChangeListener( final FileEditorTab tab ) {
346
    tab.addTextChangeListener(
347
        ( editor, oldValue, newValue ) -> {
348
          process( tab );
349
          scrollToParagraph( getCurrentParagraphIndex() );
350
        }
351
    );
352
  }
353
354
  /**
355
   * Ensure that the keyboard events are received when a new tab is added
356
   * to the user interface.
357
   *
358
   * @param tab The tab editor that can trigger keyboard events.
359
   */
360
  private void initTabKeyEventListener( final FileEditorTab tab ) {
361
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
362
  }
363
364
  private void initScrollEventListener( final FileEditorTab tab ) {
365
    final var scrollPane = tab.getScrollPane();
366
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
367
368
    addShowListener( scrollPane, ( __ ) -> {
369
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
370
      handler.enabledProperty().bind( tab.selectedProperty() );
371
    } );
372
  }
373
374
  /**
375
   * Listen for changes to the any particular paragraph and perform a quick
376
   * spell check upon it. The style classes in the editor will be changed to
377
   * mark any spelling mistakes in the paragraph. The user may then interact
378
   * with any misspelled word (i.e., any piece of text that is marked) to
379
   * revise the spelling.
380
   *
381
   * @param tab The tab to spellcheck.
382
   */
383
  private void initSpellCheckListener( final FileEditorTab tab ) {
384
    final var editor = tab.getEditorPane().getEditor();
385
386
    addShowListener(
387
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
388
    );
389
390
    // Use the plain text changes so that notifications of style changes
391
    // are suppressed. Checking against the identity ensures that only
392
    // new text additions or deletions trigger proofreading.
393
    editor.plainTextChanges()
394
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
395
396
      // Only perform a spell check on the current paragraph. The
397
      // entire document is processed once, when opened.
398
      final var offset = change.getPosition();
399
      final var position = editor.offsetToPosition( offset, Forward );
400
      final var paraId = position.getMajor();
401
      final var paragraph = editor.getParagraph( paraId );
402
      final var text = paragraph.getText();
403
404
      // Ensure that styles aren't doubled-up.
405
      editor.clearStyle( paraId );
406
407
      spellcheck( editor, text, paraId );
408
    } );
409
  }
410
411
  /**
412
   * Listen for new tab selection events.
413
   */
414
  private void initTabChangedListener() {
415
    final FileEditorTabPane editorPane = getFileEditorPane();
416
417
    // Update the preview pane changing tabs.
418
    editorPane.addTabSelectionListener(
419
        ( tabPane, oldTab, newTab ) -> {
420
          if( newTab == null ) {
421
            // Clear the preview pane when closing an editor. When the last
422
            // tab is closed, this ensures that the preview pane is empty.
423
            getPreviewPane().clear();
424
          }
425
          else {
426
            final var tab = (FileEditorTab) newTab;
427
            updateVariableNameInjector( tab );
428
            process( tab );
429
          }
430
        }
431
    );
432
  }
433
434
  /**
435
   * Reloads the preferences from the previous session.
436
   */
437
  private void initPreferences() {
438
    initDefinitionPane();
439
    getFileEditorPane().initPreferences();
440
  }
441
442
  private void initVariableNameInjector() {
443
    updateVariableNameInjector( getActiveFileEditorTab() );
444
  }
445
446
  /**
447
   * Calls the listener when the given node is shown for the first time. The
448
   * visible property is not the same as the initial showing event; visibility
449
   * can be triggered numerous times (such as going off screen).
450
   * <p>
451
   * This is called, for example, before the drag handler can be attached,
452
   * because the scrollbar for the text editor pane must be visible.
453
   * </p>
454
   *
455
   * @param node     The node to watch for showing.
456
   * @param consumer The consumer to invoke when the event fires.
457
   */
458
  private void addShowListener(
459
      final Node node, final Consumer<Void> consumer ) {
460
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
461
        runLater( () -> {
462
          if( newShow ) {
463
            try {
464
              consumer.accept( null );
465
            } catch( final Exception ex ) {
466
              error( ex );
467
            }
468
          }
469
        } );
470
471
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
472
       .flatMap( Window::showingProperty )
473
       .addListener( listener );
474
  }
475
476
  private void scrollToParagraph( final int id ) {
477
    scrollToParagraph( id, false );
478
  }
479
480
  /**
481
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
482
   *              exist.
483
   * @param force {@code true} means to force scrolling immediately, which
484
   *              should only be attempted when it is known that the document
485
   *              has been fully rendered. Otherwise the internal map of ID
486
   *              attributes will be incomplete and scrolling will flounder.
487
   */
488
  private void scrollToParagraph( final int id, final boolean force ) {
489
    synchronized( mMutex ) {
490
      final var previewPane = getPreviewPane();
491
      final var scrollPane = previewPane.getScrollPane();
492
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
493
494
      if( force ) {
495
        previewPane.scrollTo( approxId );
496
      }
497
      else {
498
        previewPane.tryScrollTo( approxId );
499
      }
500
501
      scrollPane.repaint();
502
    }
503
  }
504
505
  private void updateVariableNameInjector( final FileEditorTab tab ) {
506
    getVariableNameInjector().addListener( tab );
507
  }
508
509
  /**
510
   * Called whenever the preview pane becomes out of sync with the file editor
511
   * tab. This can be called when the text changes, the caret paragraph
512
   * changes, or the file tab changes.
513
   *
514
   * @param tab The file editor tab that has been changed in some fashion.
515
   */
516
  private void process( final FileEditorTab tab ) {
517
    if( tab != null ) {
518
      getPreviewPane().setPath( tab.getPath() );
519
520
      final Processor<String> processor = getProcessors().computeIfAbsent(
521
          tab, p -> createProcessors( tab )
522
      );
523
524
      try {
525
        processChain( processor, tab.getEditorText() );
526
      } catch( final Exception ex ) {
527
        error( ex );
528
      }
529
    }
530
  }
531
532
  /**
533
   * Executes the processing chain, operating on the given string.
534
   *
535
   * @param handler The first processor in the chain to call.
536
   * @param text    The initial value of the text to process.
537
   * @return The final value of the text that was processed by the chain.
538
   */
539
  private String processChain( Processor<String> handler, String text ) {
540
    while( handler != null && text != null ) {
541
      text = handler.process( text );
542
      handler = handler.next();
543
    }
544
545
    return text;
546
  }
547
548
  private void renderActiveTab() {
549
    process( getActiveFileEditorTab() );
550
  }
551
552
  /**
553
   * Called when a definition source is opened.
554
   *
555
   * @param path Path to the definition source that was opened.
556
   */
557
  private void openDefinitions( final Path path ) {
558
    try {
559
      final var ds = createDefinitionSource( path );
560
      setDefinitionSource( ds );
561
562
      final var prefs = getUserPreferences();
563
      prefs.definitionPathProperty().setValue( path.toFile() );
564
      prefs.save();
565
566
      final var tooltipPath = new Tooltip( path.toString() );
567
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
568
569
      final var pane = getDefinitionPane();
570
      pane.update( ds );
571
      pane.addTreeChangeHandler( mTreeHandler );
572
      pane.addKeyEventHandler( mDefinitionKeyHandler );
573
      pane.filenameProperty().setValue( path.getFileName().toString() );
574
      pane.setTooltip( tooltipPath );
575
576
      interpolateResolvedMap();
577
    } catch( final Exception ex ) {
578
      error( ex );
579
    }
580
  }
581
582
  private void exportDefinitions( final Path path ) {
583
    try {
584
      final DefinitionPane pane = getDefinitionPane();
585
      final TreeItem<String> root = pane.getTreeView().getRoot();
586
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
587
588
      if( problemChild == null ) {
589
        getDefinitionSource().getTreeAdapter().export( root, path );
590
        getNotifier().clear();
591
      }
592
      else {
593
        final String msg = get(
594
            "yaml.error.tree.form", problemChild.getValue() );
595
        error( msg );
596
      }
597
    } catch( final Exception ex ) {
598
      error( ex );
599
    }
600
  }
601
602
  private void interpolateResolvedMap() {
603
    final Map<String, String> treeMap = getDefinitionPane().toMap();
604
    final Map<String, String> map = new HashMap<>( treeMap );
605
    MapInterpolator.interpolate( map );
606
607
    getResolvedMap().clear();
608
    getResolvedMap().putAll( map );
609
  }
610
611
  private void initDefinitionPane() {
612
    openDefinitions( getDefinitionPath() );
613
  }
614
615
  /**
616
   * Called when an exception occurs that warrants the user's attention.
617
   *
618
   * @param ex The exception with a message that the user should know about.
619
   */
620
  private void error( final Exception ex ) {
621
    getNotifier().notify( ex );
622
  }
623
624
  private void error( final String msg ) {
625
    getNotifier().notify( msg );
626
  }
627
628
  //---- File actions -------------------------------------------------------
629
630
  /**
631
   * Called when an {@link Observable} instance has changed. This is called
632
   * by both the {@link Snitch} service and the notify service. The @link
633
   * Snitch} service can be called for different file types, including
634
   * {@link DefinitionSource} instances.
635
   *
636
   * @param observable The observed instance.
637
   * @param value      The noteworthy item.
638
   */
639
  @Override
640
  public void update( final Observable observable, final Object value ) {
641
    if( value != null ) {
642
      if( observable instanceof Snitch && value instanceof Path ) {
643
        updateSelectedTab();
644
      }
645
      else if( observable instanceof Notifier && value instanceof String ) {
646
        updateStatusBar( (String) value );
647
      }
648
    }
649
  }
650
651
  /**
652
   * Updates the status bar to show the given message.
653
   *
654
   * @param s The message to show in the status bar.
655
   */
656
  private void updateStatusBar( final String s ) {
657
    runLater(
658
        () -> {
659
          final int index = s.indexOf( '\n' );
660
          final String message = s.substring(
661
              0, index > 0 ? index : s.length() );
662
663
          getStatusBar().setText( message );
664
        }
665
    );
666
  }
667
668
  /**
669
   * Called when a file has been modified.
670
   */
671
  private void updateSelectedTab() {
672
    runLater(
673
        () -> {
674
          // Brute-force XSLT file reload by re-instantiating all processors.
675
          resetProcessors();
676
          renderActiveTab();
677
        }
678
    );
679
  }
680
681
  /**
682
   * After resetting the processors, they will refresh anew to be up-to-date
683
   * with the files (text and definition) currently loaded into the editor.
684
   */
685
  private void resetProcessors() {
686
    getProcessors().clear();
687
  }
688
689
  //---- File actions -------------------------------------------------------
690
691
  private void fileNew() {
692
    getFileEditorPane().newEditor();
693
  }
694
695
  private void fileOpen() {
696
    getFileEditorPane().openFileDialog();
697
  }
698
699
  private void fileClose() {
700
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
701
  }
702
703
  /**
704
   * TODO: Upon closing, first remove the tab change listeners. (There's no
705
   * need to re-render each tab when all are being closed.)
706
   */
707
  private void fileCloseAll() {
708
    getFileEditorPane().closeAllEditors();
709
  }
710
711
  private void fileSave() {
712
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
713
  }
714
715
  private void fileSaveAs() {
716
    final FileEditorTab editor = getActiveFileEditorTab();
717
    getFileEditorPane().saveEditorAs( editor );
718
    getProcessors().remove( editor );
719
720
    try {
721
      process( editor );
722
    } catch( final Exception ex ) {
723
      error( ex );
724
    }
725
  }
726
727
  private void fileSaveAll() {
728
    getFileEditorPane().saveAllEditors();
729
  }
730
731
  private void fileExit() {
732
    final Window window = getWindow();
733
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
734
  }
735
736
  //---- Edit actions -------------------------------------------------------
737
738
  /**
739
   * Transform the Markdown into HTML then copy that HTML into the copy
740
   * buffer.
741
   */
742
  private void copyHtml() {
743
    final var markdown = getActiveEditorPane().getText();
744
    final var processors = createProcessorFactory().createProcessors(
745
        getActiveFileEditorTab()
746
    );
747
748
    final var chain = processors.remove( HtmlPreviewProcessor.class );
749
750
    final String html = processChain( chain, markdown );
751
752
    final Clipboard clipboard = Clipboard.getSystemClipboard();
753
    final ClipboardContent content = new ClipboardContent();
754
    content.putString( html );
755
    clipboard.setContent( content );
756
  }
757
758
  /**
759
   * Used to find text in the active file editor window.
760
   */
761
  private void editFind() {
762
    final TextField input = getFindTextField();
763
    getStatusBar().setGraphic( input );
764
    input.requestFocus();
765
  }
766
767
  public void editFindNext() {
768
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
769
  }
770
771
  public void editPreferences() {
772
    getUserPreferences().show();
773
  }
774
775
  //---- Insert actions -----------------------------------------------------
776
777
  /**
778
   * Delegates to the active editor to handle wrapping the current text
779
   * selection with leading and trailing strings.
780
   *
781
   * @param leading  The string to put before the selection.
782
   * @param trailing The string to put after the selection.
783
   */
784
  private void insertMarkdown(
785
      final String leading, final String trailing ) {
786
    getActiveEditorPane().surroundSelection( leading, trailing );
787
  }
788
789
  private void insertMarkdown(
790
      final String leading, final String trailing, final String hint ) {
791
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
792
  }
793
794
  //---- Help actions -------------------------------------------------------
795
796
  private void helpAbout() {
797
    final Alert alert = new Alert( AlertType.INFORMATION );
798
    alert.setTitle( get( "Dialog.about.title" ) );
799
    alert.setHeaderText( get( "Dialog.about.header" ) );
800
    alert.setContentText( get( "Dialog.about.content" ) );
801
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
802
    alert.initOwner( getWindow() );
803
804
    alert.showAndWait();
805
  }
806
807
  //---- Member creators ----------------------------------------------------
808
809
  private SpellChecker createSpellChecker() {
810
    try {
811
      final Collection<String> lexicon = readLexicon( "en.txt" );
812
      return SymSpellSpeller.forLexicon( lexicon );
813
    } catch( final Exception ex ) {
814
      error( ex );
815
      return new PermissiveSpeller();
816
    }
817
  }
818
819
  /**
820
   * Factory to create processors that are suited to different file types.
821
   *
822
   * @param tab The tab that is subjected to processing.
823
   * @return A processor suited to the file type specified by the tab's path.
824
   */
825
  private Processor<String> createProcessors( final FileEditorTab tab ) {
826
    return createProcessorFactory().createProcessors( tab );
827
  }
828
829
  private ProcessorFactory createProcessorFactory() {
830
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
831
  }
832
833
  private HTMLPreviewPane createHTMLPreviewPane() {
834
    return new HTMLPreviewPane();
835
  }
836
837
  private DefinitionSource createDefaultDefinitionSource() {
838
    return new YamlDefinitionSource( getDefinitionPath() );
839
  }
840
841
  private DefinitionSource createDefinitionSource( final Path path ) {
842
    try {
843
      return createDefinitionFactory().createDefinitionSource( path );
844
    } catch( final Exception ex ) {
845
      error( ex );
846
      return createDefaultDefinitionSource();
847
    }
848
  }
849
850
  private TextField createFindTextField() {
851
    return new TextField();
852
  }
853
854
  private DefinitionFactory createDefinitionFactory() {
855
    return new DefinitionFactory();
856
  }
857
858
  private StatusBar createStatusBar() {
859
    return new StatusBar();
860
  }
861
862
  private Scene createScene() {
863
    final SplitPane splitPane = new SplitPane(
864
        getDefinitionPane().getNode(),
865
        getFileEditorPane().getNode(),
866
        getPreviewPane().getNode() );
867
868
    splitPane.setDividerPositions(
869
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
870
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
871
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
872
873
    getDefinitionPane().prefHeightProperty()
874
                       .bind( splitPane.heightProperty() );
875
876
    final BorderPane borderPane = new BorderPane();
877
    borderPane.setPrefSize( 1280, 800 );
878
    borderPane.setTop( createMenuBar() );
879
    borderPane.setBottom( getStatusBar() );
880
    borderPane.setCenter( splitPane );
881
882
    final VBox statusBar = new VBox();
883
    statusBar.setAlignment( Pos.BASELINE_CENTER );
884
    statusBar.getChildren().add( getLineNumberText() );
885
    getStatusBar().getRightItems().add( statusBar );
886
887
    // Force preview pane refresh on Windows.
888
    if( SystemUtils.IS_OS_WINDOWS ) {
889
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
890
          ( l, oValue, nValue ) -> runLater(
891
              () -> getPreviewPane().getScrollPane().repaint()
892
          )
893
      );
894
    }
895
896
    return new Scene( borderPane );
897
  }
898
899
  private Text createLineNumberText() {
900
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
901
  }
902
903
  private Node createMenuBar() {
904
    final BooleanBinding activeFileEditorIsNull =
905
        getFileEditorPane().activeFileEditorProperty().isNull();
906
907
    // File actions
908
    final Action fileNewAction = new ActionBuilder()
909
        .setText( "Main.menu.file.new" )
910
        .setAccelerator( "Shortcut+N" )
911
        .setIcon( FILE_ALT )
912
        .setAction( e -> fileNew() )
913
        .build();
914
    final Action fileOpenAction = new ActionBuilder()
915
        .setText( "Main.menu.file.open" )
916
        .setAccelerator( "Shortcut+O" )
917
        .setIcon( FOLDER_OPEN_ALT )
918
        .setAction( e -> fileOpen() )
919
        .build();
920
    final Action fileCloseAction = new ActionBuilder()
921
        .setText( "Main.menu.file.close" )
922
        .setAccelerator( "Shortcut+W" )
923
        .setAction( e -> fileClose() )
924
        .setDisable( activeFileEditorIsNull )
925
        .build();
926
    final Action fileCloseAllAction = new ActionBuilder()
927
        .setText( "Main.menu.file.close_all" )
928
        .setAction( e -> fileCloseAll() )
929
        .setDisable( activeFileEditorIsNull )
930
        .build();
931
    final Action fileSaveAction = new ActionBuilder()
932
        .setText( "Main.menu.file.save" )
933
        .setAccelerator( "Shortcut+S" )
934
        .setIcon( FLOPPY_ALT )
935
        .setAction( e -> fileSave() )
936
        .setDisable( createActiveBooleanProperty(
937
            FileEditorTab::modifiedProperty ).not() )
938
        .build();
939
    final Action fileSaveAsAction = new ActionBuilder()
940
        .setText( "Main.menu.file.save_as" )
941
        .setAction( e -> fileSaveAs() )
942
        .setDisable( activeFileEditorIsNull )
943
        .build();
944
    final Action fileSaveAllAction = new ActionBuilder()
945
        .setText( "Main.menu.file.save_all" )
946
        .setAccelerator( "Shortcut+Shift+S" )
947
        .setAction( e -> fileSaveAll() )
948
        .setDisable( Bindings.not(
949
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
950
        .build();
951
    final Action fileExitAction = new ActionBuilder()
952
        .setText( "Main.menu.file.exit" )
953
        .setAction( e -> fileExit() )
954
        .build();
955
956
    // Edit actions
957
    final Action editCopyHtmlAction = new ActionBuilder()
958
        .setText( Messages.get( "Main.menu.edit.copy.html" ) )
959
        .setIcon( HTML5 )
960
        .setAction( e -> copyHtml() )
961
        .setDisable( activeFileEditorIsNull )
962
        .build();
963
964
    final Action editUndoAction = new ActionBuilder()
965
        .setText( "Main.menu.edit.undo" )
966
        .setAccelerator( "Shortcut+Z" )
967
        .setIcon( UNDO )
968
        .setAction( e -> getActiveEditorPane().undo() )
969
        .setDisable( createActiveBooleanProperty(
970
            FileEditorTab::canUndoProperty ).not() )
971
        .build();
972
    final Action editRedoAction = new ActionBuilder()
973
        .setText( "Main.menu.edit.redo" )
974
        .setAccelerator( "Shortcut+Y" )
975
        .setIcon( REPEAT )
976
        .setAction( e -> getActiveEditorPane().redo() )
977
        .setDisable( createActiveBooleanProperty(
978
            FileEditorTab::canRedoProperty ).not() )
979
        .build();
980
981
    final Action editCutAction = new ActionBuilder()
982
        .setText( Messages.get( "Main.menu.edit.cut" ) )
983
        .setAccelerator( "Shortcut+X" )
984
        .setIcon( CUT )
985
        .setAction( e -> getActiveEditorPane().cut() )
986
        .setDisable( activeFileEditorIsNull )
987
        .build();
988
    final Action editCopyAction = new ActionBuilder()
989
        .setText( Messages.get( "Main.menu.edit.copy" ) )
990
        .setAccelerator( "Shortcut+C" )
991
        .setIcon( COPY )
992
        .setAction( e -> getActiveEditorPane().copy() )
993
        .setDisable( activeFileEditorIsNull )
994
        .build();
995
    final Action editPasteAction = new ActionBuilder()
996
        .setText( Messages.get( "Main.menu.edit.paste" ) )
997
        .setAccelerator( "Shortcut+V" )
998
        .setIcon( PASTE )
999
        .setAction( e -> getActiveEditorPane().paste() )
1000
        .setDisable( activeFileEditorIsNull )
1001
        .build();
1002
    final Action editSelectAllAction = new ActionBuilder()
1003
        .setText( Messages.get( "Main.menu.edit.selectAll" ) )
1004
        .setAccelerator( "Shortcut+A" )
1005
        .setAction( e -> getActiveEditorPane().selectAll() )
1006
        .setDisable( activeFileEditorIsNull )
1007
        .build();
1008
1009
    final Action editFindAction = new ActionBuilder()
1010
        .setText( "Main.menu.edit.find" )
1011
        .setAccelerator( "Ctrl+F" )
1012
        .setIcon( SEARCH )
1013
        .setAction( e -> editFind() )
1014
        .setDisable( activeFileEditorIsNull )
1015
        .build();
1016
    final Action editFindNextAction = new ActionBuilder()
1017
        .setText( "Main.menu.edit.find.next" )
1018
        .setAccelerator( "F3" )
1019
        .setIcon( null )
1020
        .setAction( e -> editFindNext() )
1021
        .setDisable( activeFileEditorIsNull )
1022
        .build();
1023
    final Action editPreferencesAction = new ActionBuilder()
1024
        .setText( "Main.menu.edit.preferences" )
1025
        .setAccelerator( "Ctrl+Alt+S" )
1026
        .setAction( e -> editPreferences() )
1027
        .build();
1028
1029
    // Insert actions
1030
    final Action insertBoldAction = new ActionBuilder()
1031
        .setText( "Main.menu.insert.bold" )
1032
        .setAccelerator( "Shortcut+B" )
1033
        .setIcon( BOLD )
1034
        .setAction( e -> insertMarkdown( "**", "**" ) )
1035
        .setDisable( activeFileEditorIsNull )
1036
        .build();
1037
    final Action insertItalicAction = new ActionBuilder()
1038
        .setText( "Main.menu.insert.italic" )
1039
        .setAccelerator( "Shortcut+I" )
1040
        .setIcon( ITALIC )
1041
        .setAction( e -> insertMarkdown( "*", "*" ) )
1042
        .setDisable( activeFileEditorIsNull )
1043
        .build();
1044
    final Action insertSuperscriptAction = new ActionBuilder()
1045
        .setText( "Main.menu.insert.superscript" )
1046
        .setAccelerator( "Shortcut+[" )
1047
        .setIcon( SUPERSCRIPT )
1048
        .setAction( e -> insertMarkdown( "^", "^" ) )
1049
        .setDisable( activeFileEditorIsNull )
1050
        .build();
1051
    final Action insertSubscriptAction = new ActionBuilder()
1052
        .setText( "Main.menu.insert.subscript" )
1053
        .setAccelerator( "Shortcut+]" )
1054
        .setIcon( SUBSCRIPT )
1055
        .setAction( e -> insertMarkdown( "~", "~" ) )
1056
        .setDisable( activeFileEditorIsNull )
1057
        .build();
1058
    final Action insertStrikethroughAction = new ActionBuilder()
1059
        .setText( "Main.menu.insert.strikethrough" )
1060
        .setAccelerator( "Shortcut+T" )
1061
        .setIcon( STRIKETHROUGH )
1062
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1063
        .setDisable( activeFileEditorIsNull )
1064
        .build();
1065
    final Action insertBlockquoteAction = new ActionBuilder()
1066
        .setText( "Main.menu.insert.blockquote" )
1067
        .setAccelerator( "Ctrl+Q" )
1068
        .setIcon( QUOTE_LEFT )
1069
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1070
        .setDisable( activeFileEditorIsNull )
1071
        .build();
1072
    final Action insertCodeAction = new ActionBuilder()
1073
        .setText( "Main.menu.insert.code" )
1074
        .setAccelerator( "Shortcut+K" )
1075
        .setIcon( CODE )
1076
        .setAction( e -> insertMarkdown( "`", "`" ) )
1077
        .setDisable( activeFileEditorIsNull )
1078
        .build();
1079
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1080
        .setText( "Main.menu.insert.fenced_code_block" )
1081
        .setAccelerator( "Shortcut+Shift+K" )
1082
        .setIcon( FILE_CODE_ALT )
1083
        .setAction( e -> insertMarkdown(
1084
            "\n\n```\n",
1085
            "\n```\n\n",
1086
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1087
        .setDisable( activeFileEditorIsNull )
1088
        .build();
1089
    final Action insertLinkAction = new ActionBuilder()
1090
        .setText( "Main.menu.insert.link" )
1091
        .setAccelerator( "Shortcut+L" )
1092
        .setIcon( LINK )
1093
        .setAction( e -> getActiveEditorPane().insertLink() )
1094
        .setDisable( activeFileEditorIsNull )
1095
        .build();
1096
    final Action insertImageAction = new ActionBuilder()
1097
        .setText( "Main.menu.insert.image" )
1098
        .setAccelerator( "Shortcut+G" )
1099
        .setIcon( PICTURE_ALT )
1100
        .setAction( e -> getActiveEditorPane().insertImage() )
1101
        .setDisable( activeFileEditorIsNull )
1102
        .build();
1103
1104
    // Number of header actions (H1 ... H3)
1105
    final int HEADERS = 3;
1106
    final Action[] headers = new Action[ HEADERS ];
1107
1108
    for( int i = 1; i <= HEADERS; i++ ) {
1109
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1110
      final String markup = String.format( "%n%n%s ", hashes );
1111
      final String text = "Main.menu.insert.header." + i;
1112
      final String accelerator = "Shortcut+" + i;
1113
      final String prompt = text + ".prompt";
1114
1115
      headers[ i - 1 ] = new ActionBuilder()
1116
          .setText( text )
1117
          .setAccelerator( accelerator )
1118
          .setIcon( HEADER )
1119
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1120
          .setDisable( activeFileEditorIsNull )
1121
          .build();
1122
    }
1123
1124
    final Action insertUnorderedListAction = new ActionBuilder()
1125
        .setText( "Main.menu.insert.unordered_list" )
1126
        .setAccelerator( "Shortcut+U" )
1127
        .setIcon( LIST_UL )
1128
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1129
        .setDisable( activeFileEditorIsNull )
1130
        .build();
1131
    final Action insertOrderedListAction = new ActionBuilder()
1132
        .setText( "Main.menu.insert.ordered_list" )
1133
        .setAccelerator( "Shortcut+Shift+O" )
1134
        .setIcon( LIST_OL )
1135
        .setAction( e -> insertMarkdown(
1136
            "\n\n1. ", "" ) )
1137
        .setDisable( activeFileEditorIsNull )
1138
        .build();
1139
    final Action insertHorizontalRuleAction = new ActionBuilder()
1140
        .setText( "Main.menu.insert.horizontal_rule" )
1141
        .setAccelerator( "Shortcut+H" )
1142
        .setAction( e -> insertMarkdown(
1143
            "\n\n---\n\n", "" ) )
1144
        .setDisable( activeFileEditorIsNull )
1145
        .build();
1146
1147
    // Help actions
1148
    final Action helpAboutAction = new ActionBuilder()
1149
        .setText( "Main.menu.help.about" )
1150
        .setAction( e -> helpAbout() )
1151
        .build();
1152
1153
    //---- MenuBar ----
1154
    final Menu fileMenu = ActionUtils.createMenu(
1155
        get( "Main.menu.file" ),
1156
        fileNewAction,
1157
        fileOpenAction,
1158
        null,
1159
        fileCloseAction,
1160
        fileCloseAllAction,
1161
        null,
1162
        fileSaveAction,
1163
        fileSaveAsAction,
1164
        fileSaveAllAction,
1165
        null,
1166
        fileExitAction );
1167
1168
    final Menu editMenu = ActionUtils.createMenu(
1169
        get( "Main.menu.edit" ),
1170
        editCopyHtmlAction,
1171
        null,
1172
        editUndoAction,
1173
        editRedoAction,
1174
        null,
1175
        editCutAction,
1176
        editCopyAction,
1177
        editPasteAction,
1178
        editSelectAllAction,
1179
        null,
1180
        editFindAction,
1181
        editFindNextAction,
1182
        null,
1183
        editPreferencesAction );
1184
1185
    final Menu insertMenu = ActionUtils.createMenu(
1186
        get( "Main.menu.insert" ),
1187
        insertBoldAction,
1188
        insertItalicAction,
1189
        insertSuperscriptAction,
1190
        insertSubscriptAction,
1191
        insertStrikethroughAction,
1192
        insertBlockquoteAction,
1193
        insertCodeAction,
1194
        insertFencedCodeBlockAction,
1195
        null,
1196
        insertLinkAction,
1197
        insertImageAction,
1198
        null,
1199
        headers[ 0 ],
1200
        headers[ 1 ],
1201
        headers[ 2 ],
1202
        null,
1203
        insertUnorderedListAction,
1204
        insertOrderedListAction,
1205
        insertHorizontalRuleAction
1206
    );
1207
1208
    final Menu helpMenu = ActionUtils.createMenu(
1209
        get( "Main.menu.help" ),
1210
        helpAboutAction );
1211
1212
    final MenuBar menuBar = new MenuBar(
1213
        fileMenu,
1214
        editMenu,
1215
        insertMenu,
1216
        helpMenu );
1217
1218
    //---- ToolBar ----
1219
    final ToolBar toolBar = ActionUtils.createToolBar(
1220
        fileNewAction,
1221
        fileOpenAction,
1222
        fileSaveAction,
1223
        null,
1224
        editUndoAction,
1225
        editRedoAction,
1226
        editCutAction,
1227
        editCopyAction,
1228
        editPasteAction,
1229
        null,
1230
        insertBoldAction,
1231
        insertItalicAction,
1232
        insertSuperscriptAction,
1233
        insertSubscriptAction,
1234
        insertBlockquoteAction,
1235
        insertCodeAction,
1236
        insertFencedCodeBlockAction,
1237
        null,
1238
        insertLinkAction,
1239
        insertImageAction,
1240
        null,
1241
        headers[ 0 ],
1242
        null,
1243
        insertUnorderedListAction,
1244
        insertOrderedListAction );
1245
1246
    return new VBox( menuBar, toolBar );
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 VariableNameInjector getVariableNameInjector() {
1354
    return mVariableNameInjector;
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
  private Notifier getNotifier() {
1367
    return sNotifier;
1368
  }
1369
1370
  //---- Persistence accessors ----------------------------------------------
1371
1372
  private UserPreferences getUserPreferences() {
1373
    return sOptions.getUserPreferences();
1374
  }
1375
1376
  private Path getDefinitionPath() {
1377
    return getUserPreferences().getDefinitionPath();
1378
  }
1379
1380
  //---- Spelling -----------------------------------------------------------
1381
1382
  /**
1383
   * Delegates to {@link #spellcheck(StyleClassedTextArea, String, int)}.
1384
   *
1385
   * @param text The full document text.
1386
   */
1387
  private void spellcheck(
1388
      final StyleClassedTextArea editor, final String text ) {
1389
    spellcheck( editor, text, -1 );
1390
  }
1391
1392
  /**
1393
   * @param text   Look up words for this text in the lexicon.
1394
   * @param paraId Set to -1 to apply resulting style spans to the entire
1395
   *               text.
1396
   */
1397
  private void spellcheck(
1398
      final StyleClassedTextArea editor, final String text, final int paraId ) {
1399
    final var builder = new StyleSpansBuilder<Collection<String>>();
1400
    final var runningIndex = new AtomicInteger( 0 );
1401
1402
    getSpellChecker().proofread( text, ( prevIndex, currIndex ) -> {
1403
      // Clear styling between lexiconically absent words.
1404
      builder.add( emptyList(), prevIndex - runningIndex.get() );
1405
      builder.add( singleton( "spelling" ), currIndex - prevIndex );
1406
      runningIndex.set( currIndex );
1407
    } );
1408
1409
    // If the running index was set, at least one word triggered the listener.
1410
    if( runningIndex.get() > 0 ) {
1411
      // Clear styling after the last lexiconically absent word.
1412
      builder.add( emptyList(), text.length() - runningIndex.get() );
1413
1414
      final var spans = builder.create();
1415
1416
      if( paraId >= 0 ) {
1417
        editor.setStyleSpans( paraId, 0, spans );
1418
      }
1419
      else {
1420
        editor.setStyleSpans( 0, spans );
1421
      }
1422
    }
1423
  }
1424
1425
  @SuppressWarnings("SameParameterValue")
1426
  private Collection<String> readLexicon( final String filename )
1427
      throws Exception {
1428
    final var path = Paths.get( LEXICONS_DIRECTORY, filename ).toString();
1429
    final var classLoader = MainWindow.class.getClassLoader();
1430
1431
    try( final var resource = classLoader.getResourceAsStream( path ) ) {
1432
      assert resource != null;
1433
1434
      return new BufferedReader( new InputStreamReader( resource, UTF_8 ) )
1435
          .lines()
1436
          .collect( Collectors.toList() );
1437
    }
1438
  }
46
import com.scrivenvar.spelling.api.SpellCheckListener;
47
import com.scrivenvar.spelling.api.SpellChecker;
48
import com.scrivenvar.spelling.impl.PermissiveSpeller;
49
import com.scrivenvar.spelling.impl.SymSpellSpeller;
50
import com.scrivenvar.util.Action;
51
import com.scrivenvar.util.ActionBuilder;
52
import com.scrivenvar.util.ActionUtils;
53
import com.vladsch.flexmark.parser.Parser;
54
import com.vladsch.flexmark.util.ast.NodeVisitor;
55
import com.vladsch.flexmark.util.ast.VisitHandler;
56
import javafx.beans.binding.Bindings;
57
import javafx.beans.binding.BooleanBinding;
58
import javafx.beans.property.BooleanProperty;
59
import javafx.beans.property.SimpleBooleanProperty;
60
import javafx.beans.value.ChangeListener;
61
import javafx.beans.value.ObservableBooleanValue;
62
import javafx.beans.value.ObservableValue;
63
import javafx.collections.ListChangeListener.Change;
64
import javafx.collections.ObservableList;
65
import javafx.event.Event;
66
import javafx.event.EventHandler;
67
import javafx.geometry.Pos;
68
import javafx.scene.Node;
69
import javafx.scene.Scene;
70
import javafx.scene.control.*;
71
import javafx.scene.control.Alert.AlertType;
72
import javafx.scene.image.Image;
73
import javafx.scene.image.ImageView;
74
import javafx.scene.input.Clipboard;
75
import javafx.scene.input.ClipboardContent;
76
import javafx.scene.input.KeyEvent;
77
import javafx.scene.layout.BorderPane;
78
import javafx.scene.layout.VBox;
79
import javafx.scene.text.Text;
80
import javafx.stage.Window;
81
import javafx.stage.WindowEvent;
82
import javafx.util.Duration;
83
import org.apache.commons.lang3.SystemUtils;
84
import org.controlsfx.control.StatusBar;
85
import org.fxmisc.richtext.StyleClassedTextArea;
86
import org.fxmisc.richtext.model.StyleSpansBuilder;
87
import org.reactfx.value.Val;
88
89
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.util.StageState.*;
103
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
104
import static java.nio.charset.StandardCharsets.UTF_8;
105
import static java.util.Collections.emptyList;
106
import static java.util.Collections.singleton;
107
import static javafx.application.Platform.runLater;
108
import static javafx.event.Event.fireEvent;
109
import static javafx.scene.input.KeyCode.ENTER;
110
import static javafx.scene.input.KeyCode.TAB;
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 final static Options sOptions = Services.load( Options.class );
124
  private final static Snitch SNITCH = Services.load( Snitch.class );
125
  private final static Notifier sNotifier = Services.load( Notifier.class );
126
127
  private final Scene mScene;
128
  private final StatusBar mStatusBar;
129
  private final Text mLineNumberText;
130
  private final TextField mFindTextField;
131
  private final SpellChecker mSpellChecker;
132
133
  private final Object mMutex = new Object();
134
135
  /**
136
   * Prevents re-instantiation of processing classes.
137
   */
138
  private final Map<FileEditorTab, Processor<String>> mProcessors =
139
      new HashMap<>();
140
141
  private final Map<String, String> mResolvedMap =
142
      new HashMap<>( DEFAULT_MAP_SIZE );
143
144
  /**
145
   * Called when the definition data is changed.
146
   */
147
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
148
      mTreeHandler = event -> {
149
    exportDefinitions( getDefinitionPath() );
150
    interpolateResolvedMap();
151
    renderActiveTab();
152
  };
153
154
  /**
155
   * Called to switch to the definition pane when the user presses the TAB key.
156
   */
157
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
158
      (EventHandler<KeyEvent>) event -> {
159
        if( event.getCode() == TAB ) {
160
          getDefinitionPane().requestFocus();
161
          event.consume();
162
        }
163
      };
164
165
  /**
166
   * Called to inject the selected item when the user presses ENTER in the
167
   * definition pane.
168
   */
169
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
170
      event -> {
171
        if( event.getCode() == ENTER ) {
172
          getVariableNameInjector().injectSelectedItem();
173
        }
174
      };
175
176
  private final ChangeListener<Integer> mCaretPositionListener =
177
      ( observable, oldPosition, newPosition ) -> {
178
        final FileEditorTab tab = getActiveFileEditorTab();
179
        final EditorPane pane = tab.getEditorPane();
180
        final StyleClassedTextArea editor = pane.getEditor();
181
182
        getLineNumberText().setText(
183
            get( STATUS_BAR_LINE,
184
                 editor.getCurrentParagraph() + 1,
185
                 editor.getParagraphs().size(),
186
                 editor.getCaretPosition()
187
            )
188
        );
189
      };
190
191
  private final ChangeListener<Integer> mCaretParagraphListener =
192
      ( observable, oldIndex, newIndex ) ->
193
          scrollToParagraph( newIndex, true );
194
195
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
196
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
197
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
198
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
199
      mCaretPositionListener,
200
      mCaretParagraphListener );
201
202
  /**
203
   * Listens on the definition pane for double-click events.
204
   */
205
  private final VariableNameInjector mVariableNameInjector
206
      = new VariableNameInjector( mDefinitionPane );
207
208
  public MainWindow() {
209
    sNotifier.addObserver( this );
210
211
    mStatusBar = createStatusBar();
212
    mLineNumberText = createLineNumberText();
213
    mFindTextField = createFindTextField();
214
    mScene = createScene();
215
    mSpellChecker = createSpellChecker();
216
217
    // Add the close request listener before the window is shown.
218
    initLayout();
219
  }
220
221
  /**
222
   * Called after the stage is shown.
223
   */
224
  public void init() {
225
    initFindInput();
226
    initSnitch();
227
    initDefinitionListener();
228
    initTabAddedListener();
229
    initTabChangedListener();
230
    initPreferences();
231
    initVariableNameInjector();
232
  }
233
234
  private void initLayout() {
235
    final var appScene = getScene();
236
237
    appScene.getStylesheets().add( STYLESHEET_SCENE );
238
    appScene.windowProperty().addListener(
239
        ( unused, oldWindow, newWindow ) ->
240
            newWindow.setOnCloseRequest(
241
                e -> {
242
                  if( !getFileEditorPane().closeAllEditors() ) {
243
                    e.consume();
244
                  }
245
                }
246
            )
247
    );
248
  }
249
250
  /**
251
   * Initialize the find input text field to listen on F3, ENTER, and
252
   * ESCAPE key presses.
253
   */
254
  private void initFindInput() {
255
    final TextField input = getFindTextField();
256
257
    input.setOnKeyPressed( ( KeyEvent event ) -> {
258
      switch( event.getCode() ) {
259
        case F3:
260
        case ENTER:
261
          editFindNext();
262
          break;
263
        case F:
264
          if( !event.isControlDown() ) {
265
            break;
266
          }
267
        case ESCAPE:
268
          getStatusBar().setGraphic( null );
269
          getActiveFileEditorTab().getEditorPane().requestFocus();
270
          break;
271
      }
272
    } );
273
274
    // Remove when the input field loses focus.
275
    input.focusedProperty().addListener(
276
        ( focused, oldFocus, newFocus ) -> {
277
          if( !newFocus ) {
278
            getStatusBar().setGraphic( null );
279
          }
280
        }
281
    );
282
  }
283
284
  /**
285
   * Watch for changes to external files. In particular, this awaits
286
   * modifications to any XSL files associated with XML files being edited.
287
   * When
288
   * an XSL file is modified (external to the application), the snitch's ears
289
   * perk up and the file is reloaded. This keeps the XSL transformation up to
290
   * date with what's on the file system.
291
   */
292
  private void initSnitch() {
293
    SNITCH.addObserver( this );
294
  }
295
296
  /**
297
   * Listen for {@link FileEditorTabPane} to receive open definition file
298
   * event.
299
   */
300
  private void initDefinitionListener() {
301
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
302
        ( final ObservableValue<? extends Path> file,
303
          final Path oldPath, final Path newPath ) -> {
304
          // Indirectly refresh the resolved map.
305
          resetProcessors();
306
307
          openDefinitions( newPath );
308
309
          // Will create new processors and therefore a new resolved map.
310
          renderActiveTab();
311
        }
312
    );
313
  }
314
315
  /**
316
   * When tabs are added, hook the various change listeners onto the new
317
   * tab sothat the preview pane refreshes as necessary.
318
   */
319
  private void initTabAddedListener() {
320
    final FileEditorTabPane editorPane = getFileEditorPane();
321
322
    // Make sure the text processor kicks off when new files are opened.
323
    final ObservableList<Tab> tabs = editorPane.getTabs();
324
325
    // Update the preview pane on tab changes.
326
    tabs.addListener(
327
        ( final Change<? extends Tab> change ) -> {
328
          while( change.next() ) {
329
            if( change.wasAdded() ) {
330
              // Multiple tabs can be added simultaneously.
331
              for( final Tab newTab : change.getAddedSubList() ) {
332
                final FileEditorTab tab = (FileEditorTab) newTab;
333
334
                initTextChangeListener( tab );
335
                initTabKeyEventListener( tab );
336
                initScrollEventListener( tab );
337
                initSpellCheckListener( tab );
338
//              initSyntaxListener( tab );
339
              }
340
            }
341
          }
342
        }
343
    );
344
  }
345
346
  private void initTextChangeListener( final FileEditorTab tab ) {
347
    tab.addTextChangeListener(
348
        ( editor, oldValue, newValue ) -> {
349
          process( tab );
350
          scrollToParagraph( getCurrentParagraphIndex() );
351
        }
352
    );
353
  }
354
355
  /**
356
   * Ensure that the keyboard events are received when a new tab is added
357
   * to the user interface.
358
   *
359
   * @param tab The tab editor that can trigger keyboard events.
360
   */
361
  private void initTabKeyEventListener( final FileEditorTab tab ) {
362
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
363
  }
364
365
  private void initScrollEventListener( final FileEditorTab tab ) {
366
    final var scrollPane = tab.getScrollPane();
367
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
368
369
    addShowListener( scrollPane, ( __ ) -> {
370
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
371
      handler.enabledProperty().bind( tab.selectedProperty() );
372
    } );
373
  }
374
375
  /**
376
   * Listen for changes to the any particular paragraph and perform a quick
377
   * spell check upon it. The style classes in the editor will be changed to
378
   * mark any spelling mistakes in the paragraph. The user may then interact
379
   * with any misspelled word (i.e., any piece of text that is marked) to
380
   * revise the spelling.
381
   *
382
   * @param tab The tab to spellcheck.
383
   */
384
  private void initSpellCheckListener( final FileEditorTab tab ) {
385
    final var editor = tab.getEditorPane().getEditor();
386
387
    // When the editor first appears, run a full spell check. This allows
388
    // spell checking while typing to be restricted to the active paragraph,
389
    // which is usually substantially smaller than the whole document.
390
    addShowListener(
391
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
392
    );
393
394
    // Use the plain text changes so that notifications of style changes
395
    // are suppressed. Checking against the identity ensures that only
396
    // new text additions or deletions trigger proofreading.
397
    editor.plainTextChanges()
398
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
399
400
      // Only perform a spell check on the current paragraph. The
401
      // entire document is processed once, when opened.
402
      final var offset = change.getPosition();
403
      final var position = editor.offsetToPosition( offset, Forward );
404
      final var paraId = position.getMajor();
405
      final var paragraph = editor.getParagraph( paraId );
406
      final var text = paragraph.getText();
407
408
      // Ensure that styles aren't doubled-up.
409
      editor.clearStyle( paraId );
410
411
      spellcheck( editor, text, paraId );
412
    } );
413
  }
414
415
  /**
416
   * Listen for new tab selection events.
417
   */
418
  private void initTabChangedListener() {
419
    final FileEditorTabPane editorPane = getFileEditorPane();
420
421
    // Update the preview pane changing tabs.
422
    editorPane.addTabSelectionListener(
423
        ( tabPane, oldTab, newTab ) -> {
424
          if( newTab == null ) {
425
            // Clear the preview pane when closing an editor. When the last
426
            // tab is closed, this ensures that the preview pane is empty.
427
            getPreviewPane().clear();
428
          }
429
          else {
430
            final var tab = (FileEditorTab) newTab;
431
            updateVariableNameInjector( tab );
432
            process( tab );
433
          }
434
        }
435
    );
436
  }
437
438
  /**
439
   * Reloads the preferences from the previous session.
440
   */
441
  private void initPreferences() {
442
    initDefinitionPane();
443
    getFileEditorPane().initPreferences();
444
  }
445
446
  private void initVariableNameInjector() {
447
    updateVariableNameInjector( getActiveFileEditorTab() );
448
  }
449
450
  /**
451
   * Calls the listener when the given node is shown for the first time. The
452
   * visible property is not the same as the initial showing event; visibility
453
   * can be triggered numerous times (such as going off screen).
454
   * <p>
455
   * This is called, for example, before the drag handler can be attached,
456
   * because the scrollbar for the text editor pane must be visible.
457
   * </p>
458
   *
459
   * @param node     The node to watch for showing.
460
   * @param consumer The consumer to invoke when the event fires.
461
   */
462
  private void addShowListener(
463
      final Node node, final Consumer<Void> consumer ) {
464
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
465
        runLater( () -> {
466
          if( newShow ) {
467
            try {
468
              consumer.accept( null );
469
            } catch( final Exception ex ) {
470
              error( ex );
471
            }
472
          }
473
        } );
474
475
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
476
       .flatMap( Window::showingProperty )
477
       .addListener( listener );
478
  }
479
480
  private void scrollToParagraph( final int id ) {
481
    scrollToParagraph( id, false );
482
  }
483
484
  /**
485
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
486
   *              exist.
487
   * @param force {@code true} means to force scrolling immediately, which
488
   *              should only be attempted when it is known that the document
489
   *              has been fully rendered. Otherwise the internal map of ID
490
   *              attributes will be incomplete and scrolling will flounder.
491
   */
492
  private void scrollToParagraph( final int id, final boolean force ) {
493
    synchronized( mMutex ) {
494
      final var previewPane = getPreviewPane();
495
      final var scrollPane = previewPane.getScrollPane();
496
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
497
498
      if( force ) {
499
        previewPane.scrollTo( approxId );
500
      }
501
      else {
502
        previewPane.tryScrollTo( approxId );
503
      }
504
505
      scrollPane.repaint();
506
    }
507
  }
508
509
  private void updateVariableNameInjector( final FileEditorTab tab ) {
510
    getVariableNameInjector().addListener( tab );
511
  }
512
513
  /**
514
   * Called whenever the preview pane becomes out of sync with the file editor
515
   * tab. This can be called when the text changes, the caret paragraph
516
   * changes, or the file tab changes.
517
   *
518
   * @param tab The file editor tab that has been changed in some fashion.
519
   */
520
  private void process( final FileEditorTab tab ) {
521
    if( tab != null ) {
522
      getPreviewPane().setPath( tab.getPath() );
523
524
      final Processor<String> processor = getProcessors().computeIfAbsent(
525
          tab, p -> createProcessors( tab )
526
      );
527
528
      try {
529
        processChain( processor, tab.getEditorText() );
530
      } catch( final Exception ex ) {
531
        error( ex );
532
      }
533
    }
534
  }
535
536
  /**
537
   * Executes the processing chain, operating on the given string.
538
   *
539
   * @param handler The first processor in the chain to call.
540
   * @param text    The initial value of the text to process.
541
   * @return The final value of the text that was processed by the chain.
542
   */
543
  private String processChain( Processor<String> handler, String text ) {
544
    while( handler != null && text != null ) {
545
      text = handler.process( text );
546
      handler = handler.next();
547
    }
548
549
    return text;
550
  }
551
552
  private void renderActiveTab() {
553
    process( getActiveFileEditorTab() );
554
  }
555
556
  /**
557
   * Called when a definition source is opened.
558
   *
559
   * @param path Path to the definition source that was opened.
560
   */
561
  private void openDefinitions( final Path path ) {
562
    try {
563
      final var ds = createDefinitionSource( path );
564
      setDefinitionSource( ds );
565
566
      final var prefs = getUserPreferences();
567
      prefs.definitionPathProperty().setValue( path.toFile() );
568
      prefs.save();
569
570
      final var tooltipPath = new Tooltip( path.toString() );
571
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
572
573
      final var pane = getDefinitionPane();
574
      pane.update( ds );
575
      pane.addTreeChangeHandler( mTreeHandler );
576
      pane.addKeyEventHandler( mDefinitionKeyHandler );
577
      pane.filenameProperty().setValue( path.getFileName().toString() );
578
      pane.setTooltip( tooltipPath );
579
580
      interpolateResolvedMap();
581
    } catch( final Exception ex ) {
582
      error( ex );
583
    }
584
  }
585
586
  private void exportDefinitions( final Path path ) {
587
    try {
588
      final DefinitionPane pane = getDefinitionPane();
589
      final TreeItem<String> root = pane.getTreeView().getRoot();
590
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
591
592
      if( problemChild == null ) {
593
        getDefinitionSource().getTreeAdapter().export( root, path );
594
        getNotifier().clear();
595
      }
596
      else {
597
        final String msg = get(
598
            "yaml.error.tree.form", problemChild.getValue() );
599
        error( msg );
600
      }
601
    } catch( final Exception ex ) {
602
      error( ex );
603
    }
604
  }
605
606
  private void interpolateResolvedMap() {
607
    final Map<String, String> treeMap = getDefinitionPane().toMap();
608
    final Map<String, String> map = new HashMap<>( treeMap );
609
    MapInterpolator.interpolate( map );
610
611
    getResolvedMap().clear();
612
    getResolvedMap().putAll( map );
613
  }
614
615
  private void initDefinitionPane() {
616
    openDefinitions( getDefinitionPath() );
617
  }
618
619
  /**
620
   * Called when an exception occurs that warrants the user's attention.
621
   *
622
   * @param ex The exception with a message that the user should know about.
623
   */
624
  private void error( final Exception ex ) {
625
    getNotifier().notify( ex );
626
  }
627
628
  private void error( final String msg ) {
629
    getNotifier().notify( msg );
630
  }
631
632
  //---- File actions -------------------------------------------------------
633
634
  /**
635
   * Called when an {@link Observable} instance has changed. This is called
636
   * by both the {@link Snitch} service and the notify service. The @link
637
   * Snitch} service can be called for different file types, including
638
   * {@link DefinitionSource} instances.
639
   *
640
   * @param observable The observed instance.
641
   * @param value      The noteworthy item.
642
   */
643
  @Override
644
  public void update( final Observable observable, final Object value ) {
645
    if( value != null ) {
646
      if( observable instanceof Snitch && value instanceof Path ) {
647
        updateSelectedTab();
648
      }
649
      else if( observable instanceof Notifier && value instanceof String ) {
650
        updateStatusBar( (String) value );
651
      }
652
    }
653
  }
654
655
  /**
656
   * Updates the status bar to show the given message.
657
   *
658
   * @param s The message to show in the status bar.
659
   */
660
  private void updateStatusBar( final String s ) {
661
    runLater(
662
        () -> {
663
          final int index = s.indexOf( '\n' );
664
          final String message = s.substring(
665
              0, index > 0 ? index : s.length() );
666
667
          getStatusBar().setText( message );
668
        }
669
    );
670
  }
671
672
  /**
673
   * Called when a file has been modified.
674
   */
675
  private void updateSelectedTab() {
676
    runLater(
677
        () -> {
678
          // Brute-force XSLT file reload by re-instantiating all processors.
679
          resetProcessors();
680
          renderActiveTab();
681
        }
682
    );
683
  }
684
685
  /**
686
   * After resetting the processors, they will refresh anew to be up-to-date
687
   * with the files (text and definition) currently loaded into the editor.
688
   */
689
  private void resetProcessors() {
690
    getProcessors().clear();
691
  }
692
693
  //---- File actions -------------------------------------------------------
694
695
  private void fileNew() {
696
    getFileEditorPane().newEditor();
697
  }
698
699
  private void fileOpen() {
700
    getFileEditorPane().openFileDialog();
701
  }
702
703
  private void fileClose() {
704
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
705
  }
706
707
  /**
708
   * TODO: Upon closing, first remove the tab change listeners. (There's no
709
   * need to re-render each tab when all are being closed.)
710
   */
711
  private void fileCloseAll() {
712
    getFileEditorPane().closeAllEditors();
713
  }
714
715
  private void fileSave() {
716
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
717
  }
718
719
  private void fileSaveAs() {
720
    final FileEditorTab editor = getActiveFileEditorTab();
721
    getFileEditorPane().saveEditorAs( editor );
722
    getProcessors().remove( editor );
723
724
    try {
725
      process( editor );
726
    } catch( final Exception ex ) {
727
      error( ex );
728
    }
729
  }
730
731
  private void fileSaveAll() {
732
    getFileEditorPane().saveAllEditors();
733
  }
734
735
  private void fileExit() {
736
    final Window window = getWindow();
737
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
738
  }
739
740
  //---- Edit actions -------------------------------------------------------
741
742
  /**
743
   * Transform the Markdown into HTML then copy that HTML into the copy
744
   * buffer.
745
   */
746
  private void copyHtml() {
747
    final var markdown = getActiveEditorPane().getText();
748
    final var processors = createProcessorFactory().createProcessors(
749
        getActiveFileEditorTab()
750
    );
751
752
    final var chain = processors.remove( HtmlPreviewProcessor.class );
753
754
    final String html = processChain( chain, markdown );
755
756
    final Clipboard clipboard = Clipboard.getSystemClipboard();
757
    final ClipboardContent content = new ClipboardContent();
758
    content.putString( html );
759
    clipboard.setContent( content );
760
  }
761
762
  /**
763
   * Used to find text in the active file editor window.
764
   */
765
  private void editFind() {
766
    final TextField input = getFindTextField();
767
    getStatusBar().setGraphic( input );
768
    input.requestFocus();
769
  }
770
771
  public void editFindNext() {
772
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
773
  }
774
775
  public void editPreferences() {
776
    getUserPreferences().show();
777
  }
778
779
  //---- Insert actions -----------------------------------------------------
780
781
  /**
782
   * Delegates to the active editor to handle wrapping the current text
783
   * selection with leading and trailing strings.
784
   *
785
   * @param leading  The string to put before the selection.
786
   * @param trailing The string to put after the selection.
787
   */
788
  private void insertMarkdown(
789
      final String leading, final String trailing ) {
790
    getActiveEditorPane().surroundSelection( leading, trailing );
791
  }
792
793
  private void insertMarkdown(
794
      final String leading, final String trailing, final String hint ) {
795
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
796
  }
797
798
  //---- Help actions -------------------------------------------------------
799
800
  private void helpAbout() {
801
    final Alert alert = new Alert( AlertType.INFORMATION );
802
    alert.setTitle( get( "Dialog.about.title" ) );
803
    alert.setHeaderText( get( "Dialog.about.header" ) );
804
    alert.setContentText( get( "Dialog.about.content" ) );
805
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
806
    alert.initOwner( getWindow() );
807
808
    alert.showAndWait();
809
  }
810
811
  //---- Member creators ----------------------------------------------------
812
813
  private SpellChecker createSpellChecker() {
814
    try {
815
      final Collection<String> lexicon = readLexicon( "en.txt" );
816
      return SymSpellSpeller.forLexicon( lexicon );
817
    } catch( final Exception ex ) {
818
      error( ex );
819
      return new PermissiveSpeller();
820
    }
821
  }
822
823
  /**
824
   * Factory to create processors that are suited to different file types.
825
   *
826
   * @param tab The tab that is subjected to processing.
827
   * @return A processor suited to the file type specified by the tab's path.
828
   */
829
  private Processor<String> createProcessors( final FileEditorTab tab ) {
830
    return createProcessorFactory().createProcessors( tab );
831
  }
832
833
  private ProcessorFactory createProcessorFactory() {
834
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
835
  }
836
837
  private HTMLPreviewPane createHTMLPreviewPane() {
838
    return new HTMLPreviewPane();
839
  }
840
841
  private DefinitionSource createDefaultDefinitionSource() {
842
    return new YamlDefinitionSource( getDefinitionPath() );
843
  }
844
845
  private DefinitionSource createDefinitionSource( final Path path ) {
846
    try {
847
      return createDefinitionFactory().createDefinitionSource( path );
848
    } catch( final Exception ex ) {
849
      error( ex );
850
      return createDefaultDefinitionSource();
851
    }
852
  }
853
854
  private TextField createFindTextField() {
855
    return new TextField();
856
  }
857
858
  private DefinitionFactory createDefinitionFactory() {
859
    return new DefinitionFactory();
860
  }
861
862
  private StatusBar createStatusBar() {
863
    return new StatusBar();
864
  }
865
866
  private Scene createScene() {
867
    final SplitPane splitPane = new SplitPane(
868
        getDefinitionPane().getNode(),
869
        getFileEditorPane().getNode(),
870
        getPreviewPane().getNode() );
871
872
    splitPane.setDividerPositions(
873
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
874
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
875
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
876
877
    getDefinitionPane().prefHeightProperty()
878
                       .bind( splitPane.heightProperty() );
879
880
    final BorderPane borderPane = new BorderPane();
881
    borderPane.setPrefSize( 1280, 800 );
882
    borderPane.setTop( createMenuBar() );
883
    borderPane.setBottom( getStatusBar() );
884
    borderPane.setCenter( splitPane );
885
886
    final VBox statusBar = new VBox();
887
    statusBar.setAlignment( Pos.BASELINE_CENTER );
888
    statusBar.getChildren().add( getLineNumberText() );
889
    getStatusBar().getRightItems().add( statusBar );
890
891
    // Force preview pane refresh on Windows.
892
    if( SystemUtils.IS_OS_WINDOWS ) {
893
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
894
          ( l, oValue, nValue ) -> runLater(
895
              () -> getPreviewPane().getScrollPane().repaint()
896
          )
897
      );
898
    }
899
900
    return new Scene( borderPane );
901
  }
902
903
  private Text createLineNumberText() {
904
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
905
  }
906
907
  private Node createMenuBar() {
908
    final BooleanBinding activeFileEditorIsNull =
909
        getFileEditorPane().activeFileEditorProperty().isNull();
910
911
    // File actions
912
    final Action fileNewAction = new ActionBuilder()
913
        .setText( "Main.menu.file.new" )
914
        .setAccelerator( "Shortcut+N" )
915
        .setIcon( FILE_ALT )
916
        .setAction( e -> fileNew() )
917
        .build();
918
    final Action fileOpenAction = new ActionBuilder()
919
        .setText( "Main.menu.file.open" )
920
        .setAccelerator( "Shortcut+O" )
921
        .setIcon( FOLDER_OPEN_ALT )
922
        .setAction( e -> fileOpen() )
923
        .build();
924
    final Action fileCloseAction = new ActionBuilder()
925
        .setText( "Main.menu.file.close" )
926
        .setAccelerator( "Shortcut+W" )
927
        .setAction( e -> fileClose() )
928
        .setDisable( activeFileEditorIsNull )
929
        .build();
930
    final Action fileCloseAllAction = new ActionBuilder()
931
        .setText( "Main.menu.file.close_all" )
932
        .setAction( e -> fileCloseAll() )
933
        .setDisable( activeFileEditorIsNull )
934
        .build();
935
    final Action fileSaveAction = new ActionBuilder()
936
        .setText( "Main.menu.file.save" )
937
        .setAccelerator( "Shortcut+S" )
938
        .setIcon( FLOPPY_ALT )
939
        .setAction( e -> fileSave() )
940
        .setDisable( createActiveBooleanProperty(
941
            FileEditorTab::modifiedProperty ).not() )
942
        .build();
943
    final Action fileSaveAsAction = new ActionBuilder()
944
        .setText( "Main.menu.file.save_as" )
945
        .setAction( e -> fileSaveAs() )
946
        .setDisable( activeFileEditorIsNull )
947
        .build();
948
    final Action fileSaveAllAction = new ActionBuilder()
949
        .setText( "Main.menu.file.save_all" )
950
        .setAccelerator( "Shortcut+Shift+S" )
951
        .setAction( e -> fileSaveAll() )
952
        .setDisable( Bindings.not(
953
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
954
        .build();
955
    final Action fileExitAction = new ActionBuilder()
956
        .setText( "Main.menu.file.exit" )
957
        .setAction( e -> fileExit() )
958
        .build();
959
960
    // Edit actions
961
    final Action editCopyHtmlAction = new ActionBuilder()
962
        .setText( Messages.get( "Main.menu.edit.copy.html" ) )
963
        .setIcon( HTML5 )
964
        .setAction( e -> copyHtml() )
965
        .setDisable( activeFileEditorIsNull )
966
        .build();
967
968
    final Action editUndoAction = new ActionBuilder()
969
        .setText( "Main.menu.edit.undo" )
970
        .setAccelerator( "Shortcut+Z" )
971
        .setIcon( UNDO )
972
        .setAction( e -> getActiveEditorPane().undo() )
973
        .setDisable( createActiveBooleanProperty(
974
            FileEditorTab::canUndoProperty ).not() )
975
        .build();
976
    final Action editRedoAction = new ActionBuilder()
977
        .setText( "Main.menu.edit.redo" )
978
        .setAccelerator( "Shortcut+Y" )
979
        .setIcon( REPEAT )
980
        .setAction( e -> getActiveEditorPane().redo() )
981
        .setDisable( createActiveBooleanProperty(
982
            FileEditorTab::canRedoProperty ).not() )
983
        .build();
984
985
    final Action editCutAction = new ActionBuilder()
986
        .setText( Messages.get( "Main.menu.edit.cut" ) )
987
        .setAccelerator( "Shortcut+X" )
988
        .setIcon( CUT )
989
        .setAction( e -> getActiveEditorPane().cut() )
990
        .setDisable( activeFileEditorIsNull )
991
        .build();
992
    final Action editCopyAction = new ActionBuilder()
993
        .setText( Messages.get( "Main.menu.edit.copy" ) )
994
        .setAccelerator( "Shortcut+C" )
995
        .setIcon( COPY )
996
        .setAction( e -> getActiveEditorPane().copy() )
997
        .setDisable( activeFileEditorIsNull )
998
        .build();
999
    final Action editPasteAction = new ActionBuilder()
1000
        .setText( Messages.get( "Main.menu.edit.paste" ) )
1001
        .setAccelerator( "Shortcut+V" )
1002
        .setIcon( PASTE )
1003
        .setAction( e -> getActiveEditorPane().paste() )
1004
        .setDisable( activeFileEditorIsNull )
1005
        .build();
1006
    final Action editSelectAllAction = new ActionBuilder()
1007
        .setText( Messages.get( "Main.menu.edit.selectAll" ) )
1008
        .setAccelerator( "Shortcut+A" )
1009
        .setAction( e -> getActiveEditorPane().selectAll() )
1010
        .setDisable( activeFileEditorIsNull )
1011
        .build();
1012
1013
    final Action editFindAction = new ActionBuilder()
1014
        .setText( "Main.menu.edit.find" )
1015
        .setAccelerator( "Ctrl+F" )
1016
        .setIcon( SEARCH )
1017
        .setAction( e -> editFind() )
1018
        .setDisable( activeFileEditorIsNull )
1019
        .build();
1020
    final Action editFindNextAction = new ActionBuilder()
1021
        .setText( "Main.menu.edit.find.next" )
1022
        .setAccelerator( "F3" )
1023
        .setIcon( null )
1024
        .setAction( e -> editFindNext() )
1025
        .setDisable( activeFileEditorIsNull )
1026
        .build();
1027
    final Action editPreferencesAction = new ActionBuilder()
1028
        .setText( "Main.menu.edit.preferences" )
1029
        .setAccelerator( "Ctrl+Alt+S" )
1030
        .setAction( e -> editPreferences() )
1031
        .build();
1032
1033
    // Insert actions
1034
    final Action insertBoldAction = new ActionBuilder()
1035
        .setText( "Main.menu.insert.bold" )
1036
        .setAccelerator( "Shortcut+B" )
1037
        .setIcon( BOLD )
1038
        .setAction( e -> insertMarkdown( "**", "**" ) )
1039
        .setDisable( activeFileEditorIsNull )
1040
        .build();
1041
    final Action insertItalicAction = new ActionBuilder()
1042
        .setText( "Main.menu.insert.italic" )
1043
        .setAccelerator( "Shortcut+I" )
1044
        .setIcon( ITALIC )
1045
        .setAction( e -> insertMarkdown( "*", "*" ) )
1046
        .setDisable( activeFileEditorIsNull )
1047
        .build();
1048
    final Action insertSuperscriptAction = new ActionBuilder()
1049
        .setText( "Main.menu.insert.superscript" )
1050
        .setAccelerator( "Shortcut+[" )
1051
        .setIcon( SUPERSCRIPT )
1052
        .setAction( e -> insertMarkdown( "^", "^" ) )
1053
        .setDisable( activeFileEditorIsNull )
1054
        .build();
1055
    final Action insertSubscriptAction = new ActionBuilder()
1056
        .setText( "Main.menu.insert.subscript" )
1057
        .setAccelerator( "Shortcut+]" )
1058
        .setIcon( SUBSCRIPT )
1059
        .setAction( e -> insertMarkdown( "~", "~" ) )
1060
        .setDisable( activeFileEditorIsNull )
1061
        .build();
1062
    final Action insertStrikethroughAction = new ActionBuilder()
1063
        .setText( "Main.menu.insert.strikethrough" )
1064
        .setAccelerator( "Shortcut+T" )
1065
        .setIcon( STRIKETHROUGH )
1066
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1067
        .setDisable( activeFileEditorIsNull )
1068
        .build();
1069
    final Action insertBlockquoteAction = new ActionBuilder()
1070
        .setText( "Main.menu.insert.blockquote" )
1071
        .setAccelerator( "Ctrl+Q" )
1072
        .setIcon( QUOTE_LEFT )
1073
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1074
        .setDisable( activeFileEditorIsNull )
1075
        .build();
1076
    final Action insertCodeAction = new ActionBuilder()
1077
        .setText( "Main.menu.insert.code" )
1078
        .setAccelerator( "Shortcut+K" )
1079
        .setIcon( CODE )
1080
        .setAction( e -> insertMarkdown( "`", "`" ) )
1081
        .setDisable( activeFileEditorIsNull )
1082
        .build();
1083
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1084
        .setText( "Main.menu.insert.fenced_code_block" )
1085
        .setAccelerator( "Shortcut+Shift+K" )
1086
        .setIcon( FILE_CODE_ALT )
1087
        .setAction( e -> insertMarkdown(
1088
            "\n\n```\n",
1089
            "\n```\n\n",
1090
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1091
        .setDisable( activeFileEditorIsNull )
1092
        .build();
1093
    final Action insertLinkAction = new ActionBuilder()
1094
        .setText( "Main.menu.insert.link" )
1095
        .setAccelerator( "Shortcut+L" )
1096
        .setIcon( LINK )
1097
        .setAction( e -> getActiveEditorPane().insertLink() )
1098
        .setDisable( activeFileEditorIsNull )
1099
        .build();
1100
    final Action insertImageAction = new ActionBuilder()
1101
        .setText( "Main.menu.insert.image" )
1102
        .setAccelerator( "Shortcut+G" )
1103
        .setIcon( PICTURE_ALT )
1104
        .setAction( e -> getActiveEditorPane().insertImage() )
1105
        .setDisable( activeFileEditorIsNull )
1106
        .build();
1107
1108
    // Number of header actions (H1 ... H3)
1109
    final int HEADERS = 3;
1110
    final Action[] headers = new Action[ HEADERS ];
1111
1112
    for( int i = 1; i <= HEADERS; i++ ) {
1113
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1114
      final String markup = String.format( "%n%n%s ", hashes );
1115
      final String text = "Main.menu.insert.header." + i;
1116
      final String accelerator = "Shortcut+" + i;
1117
      final String prompt = text + ".prompt";
1118
1119
      headers[ i - 1 ] = new ActionBuilder()
1120
          .setText( text )
1121
          .setAccelerator( accelerator )
1122
          .setIcon( HEADER )
1123
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1124
          .setDisable( activeFileEditorIsNull )
1125
          .build();
1126
    }
1127
1128
    final Action insertUnorderedListAction = new ActionBuilder()
1129
        .setText( "Main.menu.insert.unordered_list" )
1130
        .setAccelerator( "Shortcut+U" )
1131
        .setIcon( LIST_UL )
1132
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1133
        .setDisable( activeFileEditorIsNull )
1134
        .build();
1135
    final Action insertOrderedListAction = new ActionBuilder()
1136
        .setText( "Main.menu.insert.ordered_list" )
1137
        .setAccelerator( "Shortcut+Shift+O" )
1138
        .setIcon( LIST_OL )
1139
        .setAction( e -> insertMarkdown(
1140
            "\n\n1. ", "" ) )
1141
        .setDisable( activeFileEditorIsNull )
1142
        .build();
1143
    final Action insertHorizontalRuleAction = new ActionBuilder()
1144
        .setText( "Main.menu.insert.horizontal_rule" )
1145
        .setAccelerator( "Shortcut+H" )
1146
        .setAction( e -> insertMarkdown(
1147
            "\n\n---\n\n", "" ) )
1148
        .setDisable( activeFileEditorIsNull )
1149
        .build();
1150
1151
    // Help actions
1152
    final Action helpAboutAction = new ActionBuilder()
1153
        .setText( "Main.menu.help.about" )
1154
        .setAction( e -> helpAbout() )
1155
        .build();
1156
1157
    //---- MenuBar ----
1158
    final Menu fileMenu = ActionUtils.createMenu(
1159
        get( "Main.menu.file" ),
1160
        fileNewAction,
1161
        fileOpenAction,
1162
        null,
1163
        fileCloseAction,
1164
        fileCloseAllAction,
1165
        null,
1166
        fileSaveAction,
1167
        fileSaveAsAction,
1168
        fileSaveAllAction,
1169
        null,
1170
        fileExitAction );
1171
1172
    final Menu editMenu = ActionUtils.createMenu(
1173
        get( "Main.menu.edit" ),
1174
        editCopyHtmlAction,
1175
        null,
1176
        editUndoAction,
1177
        editRedoAction,
1178
        null,
1179
        editCutAction,
1180
        editCopyAction,
1181
        editPasteAction,
1182
        editSelectAllAction,
1183
        null,
1184
        editFindAction,
1185
        editFindNextAction,
1186
        null,
1187
        editPreferencesAction );
1188
1189
    final Menu insertMenu = ActionUtils.createMenu(
1190
        get( "Main.menu.insert" ),
1191
        insertBoldAction,
1192
        insertItalicAction,
1193
        insertSuperscriptAction,
1194
        insertSubscriptAction,
1195
        insertStrikethroughAction,
1196
        insertBlockquoteAction,
1197
        insertCodeAction,
1198
        insertFencedCodeBlockAction,
1199
        null,
1200
        insertLinkAction,
1201
        insertImageAction,
1202
        null,
1203
        headers[ 0 ],
1204
        headers[ 1 ],
1205
        headers[ 2 ],
1206
        null,
1207
        insertUnorderedListAction,
1208
        insertOrderedListAction,
1209
        insertHorizontalRuleAction
1210
    );
1211
1212
    final Menu helpMenu = ActionUtils.createMenu(
1213
        get( "Main.menu.help" ),
1214
        helpAboutAction );
1215
1216
    final MenuBar menuBar = new MenuBar(
1217
        fileMenu,
1218
        editMenu,
1219
        insertMenu,
1220
        helpMenu );
1221
1222
    //---- ToolBar ----
1223
    final ToolBar toolBar = ActionUtils.createToolBar(
1224
        fileNewAction,
1225
        fileOpenAction,
1226
        fileSaveAction,
1227
        null,
1228
        editUndoAction,
1229
        editRedoAction,
1230
        editCutAction,
1231
        editCopyAction,
1232
        editPasteAction,
1233
        null,
1234
        insertBoldAction,
1235
        insertItalicAction,
1236
        insertSuperscriptAction,
1237
        insertSubscriptAction,
1238
        insertBlockquoteAction,
1239
        insertCodeAction,
1240
        insertFencedCodeBlockAction,
1241
        null,
1242
        insertLinkAction,
1243
        insertImageAction,
1244
        null,
1245
        headers[ 0 ],
1246
        null,
1247
        insertUnorderedListAction,
1248
        insertOrderedListAction );
1249
1250
    return new VBox( menuBar, toolBar );
1251
  }
1252
1253
  /**
1254
   * Creates a boolean property that is bound to another boolean value of the
1255
   * active editor.
1256
   */
1257
  private BooleanProperty createActiveBooleanProperty(
1258
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1259
1260
    final BooleanProperty b = new SimpleBooleanProperty();
1261
    final FileEditorTab tab = getActiveFileEditorTab();
1262
1263
    if( tab != null ) {
1264
      b.bind( func.apply( tab ) );
1265
    }
1266
1267
    getFileEditorPane().activeFileEditorProperty().addListener(
1268
        ( observable, oldFileEditor, newFileEditor ) -> {
1269
          b.unbind();
1270
1271
          if( newFileEditor == null ) {
1272
            b.set( false );
1273
          }
1274
          else {
1275
            b.bind( func.apply( newFileEditor ) );
1276
          }
1277
        }
1278
    );
1279
1280
    return b;
1281
  }
1282
1283
  //---- Convenience accessors ----------------------------------------------
1284
1285
  private Preferences getPreferences() {
1286
    return sOptions.getState();
1287
  }
1288
1289
  private int getCurrentParagraphIndex() {
1290
    return getActiveEditorPane().getCurrentParagraphIndex();
1291
  }
1292
1293
  private float getFloat( final String key, final float defaultValue ) {
1294
    return getPreferences().getFloat( key, defaultValue );
1295
  }
1296
1297
  public Window getWindow() {
1298
    return getScene().getWindow();
1299
  }
1300
1301
  private MarkdownEditorPane getActiveEditorPane() {
1302
    return getActiveFileEditorTab().getEditorPane();
1303
  }
1304
1305
  private FileEditorTab getActiveFileEditorTab() {
1306
    return getFileEditorPane().getActiveFileEditor();
1307
  }
1308
1309
  //---- Member accessors ---------------------------------------------------
1310
1311
  protected Scene getScene() {
1312
    return mScene;
1313
  }
1314
1315
  private SpellChecker getSpellChecker() {
1316
    return mSpellChecker;
1317
  }
1318
1319
  private Map<FileEditorTab, Processor<String>> getProcessors() {
1320
    return mProcessors;
1321
  }
1322
1323
  private FileEditorTabPane getFileEditorPane() {
1324
    return mFileEditorPane;
1325
  }
1326
1327
  private HTMLPreviewPane getPreviewPane() {
1328
    return mPreviewPane;
1329
  }
1330
1331
  private void setDefinitionSource(
1332
      final DefinitionSource definitionSource ) {
1333
    assert definitionSource != null;
1334
    mDefinitionSource = definitionSource;
1335
  }
1336
1337
  private DefinitionSource getDefinitionSource() {
1338
    return mDefinitionSource;
1339
  }
1340
1341
  private DefinitionPane getDefinitionPane() {
1342
    return mDefinitionPane;
1343
  }
1344
1345
  private Text getLineNumberText() {
1346
    return mLineNumberText;
1347
  }
1348
1349
  private StatusBar getStatusBar() {
1350
    return mStatusBar;
1351
  }
1352
1353
  private TextField getFindTextField() {
1354
    return mFindTextField;
1355
  }
1356
1357
  private VariableNameInjector getVariableNameInjector() {
1358
    return mVariableNameInjector;
1359
  }
1360
1361
  /**
1362
   * Returns the variable map of interpolated definitions.
1363
   *
1364
   * @return A map to help dereference variables.
1365
   */
1366
  private Map<String, String> getResolvedMap() {
1367
    return mResolvedMap;
1368
  }
1369
1370
  private Notifier getNotifier() {
1371
    return sNotifier;
1372
  }
1373
1374
  //---- Persistence accessors ----------------------------------------------
1375
1376
  private UserPreferences getUserPreferences() {
1377
    return sOptions.getUserPreferences();
1378
  }
1379
1380
  private Path getDefinitionPath() {
1381
    return getUserPreferences().getDefinitionPath();
1382
  }
1383
1384
  //---- Spelling -----------------------------------------------------------
1385
1386
  /**
1387
   * Delegates to {@link #spellcheck(StyleClassedTextArea, String, int)}.
1388
   * This is called to spell check the document, rather than a single paragraph.
1389
   *
1390
   * @param text The full document text.
1391
   */
1392
  private void spellcheck(
1393
      final StyleClassedTextArea editor, final String text ) {
1394
    spellcheck( editor, text, -1 );
1395
  }
1396
1397
  /**
1398
   * Spellchecks a subset of the entire document.
1399
   *
1400
   * @param text   Look up words for this text in the lexicon.
1401
   * @param paraId Set to -1 to apply resulting style spans to the entire
1402
   *               text.
1403
   */
1404
  @SuppressWarnings("CodeBlock2Expr")
1405
  private void spellcheck(
1406
      final StyleClassedTextArea editor, final String text, final int paraId ) {
1407
    final var builder = new StyleSpansBuilder<Collection<String>>();
1408
    final var runningIndex = new AtomicInteger( 0 );
1409
    final var checker = getSpellChecker();
1410
1411
    // The text nodes must be relayed through a contextual "visitor" that
1412
    // can return text in chunks with correlative offsets into the string.
1413
    // This allows Markdown, R Markdown, XML, and R XML documents to return
1414
    // sets of words to check.
1415
1416
    final var node = mParser.parse( text );
1417
    final var visitor = new TextVisitor( ( visited, bIndex, eIndex ) -> {
1418
      checker.proofread( visited, ( misspelled, prevIndex, currIndex ) -> {
1419
        prevIndex += bIndex;
1420
        currIndex += bIndex;
1421
1422
        // Clear styling between lexiconically absent words.
1423
        builder.add( emptyList(), prevIndex - runningIndex.get() );
1424
        builder.add( singleton( "spelling" ), currIndex - prevIndex );
1425
        runningIndex.set( currIndex );
1426
      } );
1427
    } );
1428
1429
    visitor.visit( node );
1430
1431
    // If the running index was set, at least one word triggered the listener.
1432
    if( runningIndex.get() > 0 ) {
1433
      // Clear styling after the last lexiconically absent word.
1434
      builder.add( emptyList(), text.length() - runningIndex.get() );
1435
1436
      final var spans = builder.create();
1437
1438
      if( paraId >= 0 ) {
1439
        editor.setStyleSpans( paraId, 0, spans );
1440
      }
1441
      else {
1442
        editor.setStyleSpans( 0, spans );
1443
      }
1444
    }
1445
  }
1446
1447
  @SuppressWarnings("SameParameterValue")
1448
  private Collection<String> readLexicon( final String filename )
1449
      throws Exception {
1450
    final var path = Paths.get( LEXICONS_DIRECTORY, filename ).toString();
1451
    final var classLoader = MainWindow.class.getClassLoader();
1452
1453
    try( final var resource = classLoader.getResourceAsStream( path ) ) {
1454
      assert resource != null;
1455
1456
      return new BufferedReader( new InputStreamReader( resource, UTF_8 ) )
1457
          .lines()
1458
          .collect( Collectors.toList() );
1459
    }
1460
  }
1461
1462
  // TODO: Replace using Markdown processor instantiated for Markdown files.
1463
  // FIXME: https://github.com/DaveJarvis/scrivenvar/issues/59
1464
  private final Parser mParser = Parser.builder().build();
1465
1466
  // TODO: Replace with generic interface; provide Markdown/XML implementations.
1467
  // FIXME: https://github.com/DaveJarvis/scrivenvar/issues/59
1468
  private final static class TextVisitor {
1469
    private final NodeVisitor mVisitor = new NodeVisitor( new VisitHandler<>(
1470
        com.vladsch.flexmark.ast.Text.class, this::visit )
1471
    );
1472
1473
    private final SpellCheckListener mConsumer;
1474
1475
    public TextVisitor( final SpellCheckListener consumer ) {
1476
      mConsumer = consumer;
1477
    }
1478
1479
    private void visit( final com.vladsch.flexmark.util.ast.Node node ) {
1480
      if( node instanceof com.vladsch.flexmark.ast.Text ) {
1481
        mConsumer.accept( node.getChars().toString(),
1482
                          node.getStartOffset(),
1483
                          node.getEndOffset() );
1484
      }
1485
1486
      mVisitor.visitChildren( node );
1487
    }
1488
  }
1489
14391490
}
14401491
M src/main/java/com/scrivenvar/processors/markdown/ImageLinkExtension.java
114114
        }
115115
      } catch( final Exception ignored ) {
116
        // Try to dynamically resolve the image.
116
        // Try to resolve the image, dynamically.
117117
      }
118118
...
138138
        boolean missing = true;
139139
140
        // Iterate over the user's preferred image file type extensions.
140141
        for( final String ext : Splitter.on( ' ' ).split( suffixes ) ) {
141142
          final String imagePath = format( "%s.%s", imagePathPrefix, ext );
M src/main/java/com/scrivenvar/spelling/api/SpellCheckListener.java
3232
/**
3333
 * Represents an operation that accepts two input arguments and returns no
34
 * result. Unlike most other functional interfaces, {@code BiConsumer} is
35
 * expected to operate via side-effects.
34
 * result. Unlike most other functional interfaces, this class is expected to
35
 * operate via side-effects.
3636
 * <p>
3737
 * This is used instead of a {@link BiConsumer} to avoid autoboxing.
...
4444
   * Performs an operation on the given arguments.
4545
   *
46
   * @param i1 the first input argument
47
   * @param i2 the second input argument
46
   * @param text        The text associated with a beginning and ending offset.
47
   * @param beganOffset A starting offset, used as an index into a string.
48
   * @param endedOffset An ending offset, which should equal text.length() +
49
   *                    beganOffset.
4850
   */
49
  void accept( int i1, int i2 );
51
  void accept( String text, int beganOffset, int endedOffset );
5052
}
5153
M src/main/java/com/scrivenvar/spelling/impl/SymSpellSpeller.java
117117
118118
      if( isWord( substring ) && !inLexicon( substring ) ) {
119
        consumer.accept( previousIndex, boundaryIndex );
119
        consumer.accept( substring, previousIndex, boundaryIndex );
120120
      }
121121
A src/main/resources/lexicons/neologisms/README.md
1
# Overview
2
3
Lexicons in this directory are meant to relate to a particular subject
4
(medicine, chemistry, math, sports, and such) or not in common use.
5
16
A src/main/resources/lexicons/neologisms/tech.txt
1
analytics	130337
2
hands-on	130223
3
long-term	130030
4
hotspot	130022
5
instantiation	130000
6
onboarding	129953
7
e-commerce	129853
8
real-time	129837
9
biometric	129795
10
anamorphic	129777
11
state-of-the-art	129773
12
benchmarking	129772
13
cybersecurity	129769
14
barcode	129757
15
splitter	129755
16
keychain	129719
17
crowdfunding	129696
18
polymorphism	129688
19
fail-safe	129668
20
automata	129666
21
shockwave	129658
22
built-in	129653
23
profiler	129648
24
kerning	129646
25
high-level	129634
26
short-circuit	129632
27
nanometer	129630
28
one-time	129626
29
back-end	129625
30
meridiem	129624
31
influencer	129618
32
passcode	129617
33
sexting	129607
34
cryptology	129606
35
biometrics	129606
36
bitcoin	129599
37
specular	129598
38
accelerometer	129588
39
end-user	129585
40
googolplex	129583
41
voice-over	129576
42
grayscale	129576
43
ascender	129571
44
pixelated	129569
45
rockstar	129565
46
ragdoll	129564
47
cyberattack	129564
48
cryptanalysis	129562
49
high-tech	129557
50
large-scale	129554
51
ransomware	129553
52
fiber-optic	129552
53
crowdsourcing	129552
54
hackathon	129551
55
audiobook	129544
56
degauss	129543
57
attenuator	129540
58
jetpack	129538
59
front-end	129538
60
user-friendly	129537
61
packrat	129536
62
tick-tock	129535
63
backlight	129535
64
bootable	129530
65
octothorpe	129529
66
newsfeed	129525
67
peer-to-peer	129523
68
extranet	129523
69
on-screen	129519
70
first-class	129517
71
failover	129516
72
cyberbullying	129516
73
neumann	129515
74
capacitive	129514
75
backlit	129511
76
plug-in	129508
77
red-eye	129507
78
millimicron	129507
79
inductor	129505
80
drop-down	129504
81
end-to-end	129503
82
workgroup	129502
83
journaling	129500
84
middleware	129499
85
spooler	129497
86
clamshell	129495
87
wireframe	129494
88
modularity	129493
89
two-step	129490
90
strikethrough	129489
91
third-party	129487
92
petabyte	129487
93
high-quality	129483
94
jughead	129482
95
acyclic	129482
96
three-dimensional	129481
97
opt-out	129479
98
gearhead	129478
99
stateful	129473
100
drive-by	129471
101
high-tech	129468
102
submenu	129467
103
off-the-shelf	129466
104
pseudorandom	129463
105
low-level	129461
106
earbuds	129461
107
one-to-one	129460
108
narrowband	129460
109
co-location	129460
110
recordable	129457
111
on-demand	129456
112
unallocated	129455
113
mappable	129455
114
chipset	129454
115
value-added	129447
116
multicast	129447
117
loopback	129444
118
pixelate	129441
119
cryptographic	129441
120
pixelation	129438
121
high-speed	129438
122
autocorrect	129438
123
teraflop	129437
124
digitizer	129436
125
tunnelling	129434
126
deduplication	129434
127
subwoofer	129433
128
long-haul	129431
129
small-scale	129430
130
touchpad	129429
131
namespace	129428
132
microcontroller	129428
133
geolocation	129428
134
telepresence	129427
135
solid-state	129426
136
driverless	129426
137
photolithography	129425
138
multiphase	129425
139
verifier	129424
140
robocall	129424
141
autofocus	129424
142
kilobit	129422
143
hacktivist	129419
144
high-performance	129416
145
geocache	129415
146
two-dimensional	129412
147
rasterize	129412
148
plaintext	129411
149
pipelining	129411
150
technobabble	129409
151
defragment	129409
152
connectionless	129409
153
homomorphic	129407
154
hands-free	129407
155
demodulator	129406
156
datagram	129406
157
activex	129406
158
non-linear	129405
159
tut-tut	129404
160
normalisation	129404
161
blackhole	129402
162
cyberstalker	129401
163
multifunction	129400
164
push-button	129398
165
full-size	129398
166
undirected	129397
167
ciphertext	129397
168
top-level	129396
169
superspeed	129396
170
first-line	129396
171
spacebar	129395
172
cyberwar	129395
173
borderless	129395
174
transcode	129393
175
open-source	129393
176
cyberbully	129393
177
multimeter	129392
178
dropship	129391
179
yottabyte	129390
180
infector	129390
181
superclass	129389
182
just-in-time	129389
183
tooltip	129388
184
dereference	129387
185
color-coded	129387
186
double-sided	129386
187
combinator	129386
188
milliwatt	129385
189
web-based	129384
190
dial-up	129384
191
cyberstalking	129384
192
subfolder	129383
193
point-to-point	129383
194
wideband	129382
195
noncontiguous	129382
196
ferroelectric	129382
197
general-purpose	129379
198
e-reader	129379
199
case-sensitive	129379
200
cybersquatting	129378
201
autofill	129378
202
trackpad	129376
203
double-click	129376
204
associatively	129376
205
high-voltage	129375
206
luggable	129374
207
seamonkey	129373
208
right-click	129373
209
defragmentation	129373
210
starcraft	129371
211
obliquing	129371
212
leadless	129371
213
greeking	129371
214
upgradeable	129370
215
radiosity	129370
216
transcoding	129369
217
quintillionth	129369
218
bitmapped	129369
219
subdirectory	129368
220
degausser	129368
221
curtiss	129368
222
scunthorpe	129367
223
anti-aliasing	129366
224
undelete	129365
225
gigaflops	129365
226
darknet	129365
227
zettabyte	129364
228
topologies	129363
229
spidering	129363
230
read-only	129363
231
point-of-sale	129363
232
photorealism	129363
233
multithreading	129363
234
deallocate	129363
235
mersenne	129362
236
self-evaluation	129361
237
machinima	129361
238
click-through	129361
239
satisfiable	129360
240
laserjet	129360
241
multicore	129359
242
microblog	129359
243
megaflops	129359
244
homeomorphic	129359
245
e-business	129359
246
cross-platform	129359
247
microblogging	129358
248
kilobaud	129358
249
cyberwarfare	129358
250
microarchitecture	129357
251
full-page	129357
252
autosave	129357
253
wirelessly	129356
254
sneakernet	129355
255
pull-down	129355
256
fixed-point	129355
257
textbox	129354
258
obfuscator	129354
259
high-density	129354
260
high-definition	129354
261
first-person	129354
262
microkernel	129353
263
substring	129352
264
np-complete	129352
265
non-resident	129352
266
macroinstruction	129352
267
end-of-life	129352
268
endianness	129352
269
indexable	129351
270
backtick	129351
271
zero-day	129350
272
unshielded	129350
273
hyper-threading	129350
274
cleartext	129350
275
hunt-and-peck	129349
276
full-sized	129349
277
event-driven	129349
278
cross-linked	129349
279
autocomplete	129349
280
abandonware	129349
281
object-oriented	129348
282
hacktivism	129348
283
antikythera	129348
284
write-through	129347
285
stereolithography	129347
286
photorealistic	129347
287
macrovision	129347
288
greasemonkey	129347
289
geotagging	129347
290
drug-free	129347
291
middle-level	129346
292
e-mails	129346
293
disassembler	129346
294
spacewar	129345
295
pluggable	129345
296
kilobits	129345
297
anti-static	129345
298
webcomic	129344
299
unfollow	129344
300
photosensor	129344
301
petaflop	129344
302
garageband	129344
303
computer-aided	129344
304
truetype	129343
305
burn-in	129343
306
subnetwork	129342
307
backpropagation	129342
308
user-defined	129341
309
supercomputing	129340
310
smartwatch	129340
311
non-volatile	129340
312
wholly-owned	129339
313
unbundled	129339
314
smilies	129339
315
self-test	129339
316
sans-serif	129339
317
milliamp	129339
318
bytecode	129339
319
anti-alias	129339
320
menu-driven	129338
321
computer-generated	129338
322
anti-spam	129338
323
trackpoint	129337
324
third-person	129337
325
slipstreaming	129337
326
monospace	129337
327
memoization	129337
328
double-headed	129337
329
scaleable	129336
330
pop-under	129336
331
n-tuple	129336
332
multi-user	129336
333
machine-readable	129336
334
full-duplex	129336
335
server-side	129335
336
respawn	129335
337
multi-color	129335
338
multicasting	129335
339
half-duplex	129335
340
geocacher	129335
341
workgroups	129334
342
ferrofluid	129334
343
smartdrive	129333
344
random-access	129333
345
e-sports	129333
346
command-line	129333
347
subsampling	129332
348
second-generation	129332
349
rasterization	129332
350
guiltware	129332
351
diffie-hellman	129332
352
defragger	129332
353
satisfiability	129331
354
mid-level	129331
355
four-color	129331
356
dual-core	129331
357
activision	129331
358
subdirectories	129330
359
segfault	129330
360
flamebait	129330
361
drag-and-drop	129330
362
point-and-click	129329
363
on-hook	129329
364
off-hook	129329
365
framebuffer	129329
366
defragging	129329
367
decompiler	129329
368
unshift	129328
369
text-to-speech	129328
370
single-sided	129328
371
memristor	129328
372
low-power	129328
373
fifth-generation	129328
374
zebibyte	129327
375
semiprime	129327
376
rotoscoping	129327
377
left-click	129327
378
hypertransport	129327
379
cross-posting	129327
380
z-buffering	129326
381
smartmedia	129326
382
public-key	129326
383
point-and-shoot	129326
384
non-destructive	129326
385
magneto-optical	129326
386
in-game	129326
387
grayware	129326
388
fixed-width	129326
389
error-correction	129326
390
defragmenting	129326
391
defragmenter	129326
392
closed-source	129326
393
twisted-pair	129325
394
repagination	129325
395
active-matrix	129325
396
touch-sensitive	129324
397
subnetting	129324
398
speech-recognition	129324
399
skeuomorphism	129324
400
screencast	129324
401
fail-soft	129324
402
e-crime	129324
403
write-protect	129323
404
third-generation	129323
405
stylesheet	129323
406
speech-to-text	129323
407
letter-quality	129323
408
unix-like	129322
409
superintelligence	129322
410
software-as-a-service	129322
411
rule-based	129322
412
non-uniform	129322
413
multitenancy	129322
414
datastore	129322
415
autoplay	129322
416
repaginate	129321
417
pressure-sensitive	129321
418
passive-matrix	129321
419
non-return-to-zero	129321
420
macbook	129321
421
geotagged	129321
422
double-density	129321
423
cross-browser	129321
424
baudrate	129321
425
transmeta	129320
426
three-line	129320
427
surface-mount	129320
428
screwless	129320
429
nameserver	129320
430
lead-acid	129320
431
interexchange	129320
432
half-adder	129320
433
anti-spyware	129320
434
non-interlaced	129319
435
nickel-cadmium	129319
436
multi-channel	129319
437
hot-swappable	129319
438
half-height	129319
439
geocoding	129319
440
error-correcting	129319
441
end-of-file	129319
442
downloader	129319
443
autodiscovery	129319
444
anti-glare	129319
445
analog-to-digital	129319
446
extortion	65752
447
e-mail	65686
448
emoji	65684
449
googol	65618
450
wi-fi	65615
451
x-ray	65529
452
backside	65388
453
fibre	65387
454
metre	65333
455
royale	65173
456
radix	65093
457
co-op	65093
458
hotdog	65091
459
pop-up	65073
460
add-on	65064
461
lecher	65062
462
uptime	65009
463
unbound	64979
464
eniac	64975
465
synaptic	64966
466
voxel	64926
467
selfie	64917
468
cd-rom	64896
469
uplink	64887
470
opt-in	64884
471
fanboy	64857
472
defrag	64849
473
nondisclosure	64839
474
e-book	64830
475
qubit	64828
476
yippie	64821
477
gearhead	64819
478
subnet	64818
479
co-operation	64810
480
endian	64798
481
bezier	64797
482
reallocation	64796
483
telephonic	64789
484
mosfet	64777
485
y-axis	64776
486
mutex	64775
487
inkjet	64772
488
gobbing	64768
489
shader	64766
490
ultralight	64755
491
hackers	64746
492
pacman	64742
493
unlink	64741
494
undock	64740
495
understroke	64738
496
beginners	64736
497
photoscope	64731
498
gantt	64725
499
programmers	64722
500
todays	64720
501
moores	64716
502
fullscreen	64715
503
add-in	64711
504
z-axis	64710
505
moveless	64708
506
reformatted	64704
507
deallocate	64704
508
laserdisc	64702
509
macos	64700
510
e-cash	64698
511
nonactive	64697
512
nonadjacent	64696
513
non-party	64695
514
hotfix	64695
515
keylogger	64694
516
end-of-life	64693
517
geotag	64691
518
oreilly	64681
519
exabit	64678
520
jailbroken	64677
521
no-op	64676
522
fuzzer	64676
523
anti-piracy	64674
524
pascal-s	64673
525
noninteractive	64673
526
multifactor	64672
527
letterspacing	64671
528
single-use	64669
529
self-timer	64669
530
preinstall	64669
531
left-clicking	64668
532
trs-80	64667
533
multiboot	64666
534
runescape	64665
535
micropayment	64664
536
rule-based	64663
537
numpad	64663
538
preinstalled	64661
539
double-humped	64661
540
jailbreaking	64660
541
attend	2158
542
withstand	1809
543
transpire	1116
544
reading	1110
545
texture	1065
546
yay	992
547
dojo	853
548
capitalize	832
549
calling	779
550
unfold	767
551
starboard	679
552
commode	625
553
doing	594
554
textbook	499
555
unease	378
556
unpack	358
557
keycard	231
558
mainspring	207
559
grr	180
560
geocaching	167
561
microbus	160
562
mp3	147
563
shifted	128
564
texted	127
565
high-speed	120
566
towheaded	118
567
mineshaft	115
568
nonparty	95
569
man-to-man	94
570
self-positing	80
571
crossbite	80
572
double-time	79
573
resignedness	69
574
msrp	61
575
inbreak	53
576
nanocomposite	44
577
md5	44
578
neomorphic	41
579
full-blood	34
580
self-driven	32
581
superstrain	28
582
lifers	27
583
multination	26
584
smartwatch	22
585
anti-lock	22
586
antilibration	22
587
zapf	20
588
mp4	20
589
retargeting	16
590
two-to-one	12
591
robocup	12
592
left-clicked	9
593
low-impact	7
594
co-factor	7
595
half-integral	4
596
anti-theft	4
597
self-extraction	1
1598