Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M src/main/java/com/scrivenvar/MainWindow.java
7676
import javafx.stage.WindowEvent;
7777
import javafx.util.Duration;
78
import org.controlsfx.control.StatusBar;
79
import org.fxmisc.richtext.StyleClassedTextArea;
80
import org.reactfx.value.Val;
81
import org.xhtmlrenderer.util.XRLog;
82
83
import java.awt.*;
84
import java.awt.font.TextAttribute;
85
import java.io.FileInputStream;
86
import java.io.IOException;
87
import java.io.InputStream;
88
import java.net.URI;
89
import java.nio.file.Path;
90
import java.util.HashMap;
91
import java.util.Map;
92
import java.util.Observable;
93
import java.util.Observer;
94
import java.util.function.Function;
95
import java.util.prefs.Preferences;
96
97
import static com.scrivenvar.Constants.*;
98
import static com.scrivenvar.Messages.get;
99
import static com.scrivenvar.util.StageState.*;
100
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
101
import static java.awt.font.TextAttribute.LIGATURES;
102
import static java.awt.font.TextAttribute.LIGATURES_ON;
103
import static javafx.event.Event.fireEvent;
104
import static javafx.scene.input.KeyCode.ENTER;
105
import static javafx.scene.input.KeyCode.TAB;
106
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
107
108
/**
109
 * Main window containing a tab pane in the center for file editors.
110
 *
111
 * @author Karl Tauber and White Magic Software, Ltd.
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 OPTIONS = Services.load( Options.class );
120
  private final static Snitch SNITCH = Services.load( Snitch.class );
121
  private final static Notifier NOTIFIER = 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
128
  private final Object mMutex = new Object();
129
130
  /**
131
   * Prevents re-instantiation of processing classes.
132
   */
133
  private final Map<FileEditorTab, Processor<String>> mProcessors =
134
      new HashMap<>();
135
136
  private final Map<String, String> mResolvedMap =
137
      new HashMap<>( DEFAULT_MAP_SIZE );
138
139
  /**
140
   * Called when the definition data is changed.
141
   */
142
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
143
      mTreeHandler = event -> {
144
    exportDefinitions( getDefinitionPath() );
145
    interpolateResolvedMap();
146
    renderActiveTab();
147
  };
148
149
  /**
150
   * Called to switch to the definition pane when the user presses the TAB key.
151
   */
152
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
153
      (EventHandler<KeyEvent>) event -> {
154
        if( event.getCode() == TAB ) {
155
          getDefinitionPane().requestFocus();
156
          event.consume();
157
        }
158
      };
159
160
  /**
161
   * Called to inject the selected item when the user presses ENTER in the
162
   * definition pane.
163
   */
164
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
165
      event -> {
166
        if( event.getCode() == ENTER ) {
167
          getVariableNameInjector().injectSelectedItem();
168
        }
169
      };
170
171
  private final ChangeListener<Integer> mCaretPositionListener =
172
      ( observable, oldPosition, newPosition ) -> {
173
        final FileEditorTab tab = getActiveFileEditorTab();
174
        final EditorPane pane = tab.getEditorPane();
175
        final StyleClassedTextArea editor = pane.getEditor();
176
177
        getLineNumberText().setText(
178
            get( STATUS_BAR_LINE,
179
                 editor.getCurrentParagraph() + 1,
180
                 editor.getParagraphs().size(),
181
                 editor.getCaretPosition()
182
            )
183
        );
184
      };
185
186
  private final ChangeListener<Integer> mCaretParagraphListener =
187
      ( observable, oldIndex, newIndex ) ->
188
          scrollToParagraph( newIndex, true );
189
190
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
191
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
192
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
193
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
194
      mCaretPositionListener,
195
      mCaretParagraphListener );
196
197
  /**
198
   * Listens on the definition pane for double-click events.
199
   */
200
  private final VariableNameInjector mVariableNameInjector
201
      = new VariableNameInjector( mDefinitionPane );
202
203
  public MainWindow() {
204
    mStatusBar = createStatusBar();
205
    mLineNumberText = createLineNumberText();
206
    mFindTextField = createFindTextField();
207
    mScene = createScene();
208
209
    System.getProperties().setProperty("xr.util-logging.loggingEnabled", "true");
210
    XRLog.setLoggingEnabled( true);
211
212
    initLayout();
213
    initFindInput();
214
    initSnitch();
215
    initDefinitionListener();
216
    initTabAddedListener();
217
    initTabChangedListener();
218
    initPreferences();
219
    initVariableNameInjector();
220
221
    NOTIFIER.addObserver( this );
222
  }
223
224
  private void initLayout() {
225
    final Scene appScene = getScene();
226
227
    appScene.getStylesheets().add( STYLESHEET_SCENE );
228
229
    // TODO: Apply an XML syntax highlighting for XML files.
230
//    appScene.getStylesheets().add( STYLESHEET_XML );
231
    appScene.windowProperty().addListener(
232
        ( observable, oldWindow, newWindow ) ->
233
            newWindow.setOnCloseRequest(
234
                e -> {
235
                  if( !getFileEditorPane().closeAllEditors() ) {
236
                    e.consume();
237
                  }
238
                }
239
            )
240
    );
241
  }
242
243
  /**
244
   * Initialize the find input text field to listen on F3, ENTER, and
245
   * ESCAPE key presses.
246
   */
247
  private void initFindInput() {
248
    final TextField input = getFindTextField();
249
250
    input.setOnKeyPressed( ( KeyEvent event ) -> {
251
      switch( event.getCode() ) {
252
        case F3:
253
        case ENTER:
254
          editFindNext();
255
          break;
256
        case F:
257
          if( !event.isControlDown() ) {
258
            break;
259
          }
260
        case ESCAPE:
261
          getStatusBar().setGraphic( null );
262
          getActiveFileEditorTab().getEditorPane().requestFocus();
263
          break;
264
      }
265
    } );
266
267
    // Remove when the input field loses focus.
268
    input.focusedProperty().addListener(
269
        ( focused, oldFocus, newFocus ) -> {
270
          if( !newFocus ) {
271
            getStatusBar().setGraphic( null );
272
          }
273
        }
274
    );
275
  }
276
277
  /**
278
   * Watch for changes to external files. In particular, this awaits
279
   * modifications to any XSL files associated with XML files being edited.
280
   * When
281
   * an XSL file is modified (external to the application), the snitch's ears
282
   * perk up and the file is reloaded. This keeps the XSL transformation up to
283
   * date with what's on the file system.
284
   */
285
  private void initSnitch() {
286
    SNITCH.addObserver( this );
287
  }
288
289
  /**
290
   * Listen for {@link FileEditorTabPane} to receive open definition file
291
   * event.
292
   */
293
  private void initDefinitionListener() {
294
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
295
        ( final ObservableValue<? extends Path> file,
296
          final Path oldPath, final Path newPath ) -> {
297
          // Indirectly refresh the resolved map.
298
          resetProcessors();
299
300
          openDefinitions( newPath );
301
302
          // Will create new processors and therefore a new resolved map.
303
          renderActiveTab();
304
        }
305
    );
306
  }
307
308
  /**
309
   * When tabs are added, hook the various change listeners onto the new
310
   * tab sothat the preview pane refreshes as necessary.
311
   */
312
  private void initTabAddedListener() {
313
    final FileEditorTabPane editorPane = getFileEditorPane();
314
315
    // Make sure the text processor kicks off when new files are opened.
316
    final ObservableList<Tab> tabs = editorPane.getTabs();
317
318
    // Update the preview pane on tab changes.
319
    tabs.addListener(
320
        ( final Change<? extends Tab> change ) -> {
321
          while( change.next() ) {
322
            if( change.wasAdded() ) {
323
              // Multiple tabs can be added simultaneously.
324
              for( final Tab newTab : change.getAddedSubList() ) {
325
                final FileEditorTab tab = (FileEditorTab) newTab;
326
327
                initTextChangeListener( tab );
328
                initTabKeyEventListener( tab );
329
                initScrollEventListener( tab );
330
//              initSyntaxListener( tab );
331
              }
332
            }
333
          }
334
        }
335
    );
336
  }
337
338
  private void initScrollEventListener( final FileEditorTab tab ) {
339
    final var scrollPane = tab.getEditorPane().getScrollPane();
340
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
341
342
    // Before the drag handler can be attached, the scroll bar for the
343
    // text editor pane must be visible.
344
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
345
        Platform.runLater( () -> {
346
          if( newShow ) {
347
            final var handler = new ScrollEventHandler( scrollPane, scrollBar );
348
            handler.enabledProperty().bind( tab.selectedProperty() );
349
          }
350
        } );
351
352
    Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
353
       .flatMap( Window::showingProperty )
354
       .addListener( listener );
355
  }
356
357
  /**
358
   * Listen for new tab selection events.
359
   */
360
  private void initTabChangedListener() {
361
    final FileEditorTabPane editorPane = getFileEditorPane();
362
363
    // Update the preview pane changing tabs.
364
    editorPane.addTabSelectionListener(
365
        ( tabPane, oldTab, newTab ) -> {
366
          // If there was no old tab, then this is a first time load, which
367
          // can be ignored.
368
          if( oldTab != null ) {
369
            if( newTab != null ) {
370
              final FileEditorTab tab = (FileEditorTab) newTab;
371
              updateVariableNameInjector( tab );
372
              process( tab );
373
            }
374
          }
375
        }
376
    );
377
  }
378
379
  /**
380
   * Reloads the preferences from the previous session.
381
   */
382
  private void initPreferences() {
383
    initDefinitionPane();
384
    getFileEditorPane().initPreferences();
385
  }
386
387
  private void initVariableNameInjector() {
388
    updateVariableNameInjector( getActiveFileEditorTab() );
389
  }
390
391
  /**
392
   * Ensure that the keyboard events are received when a new tab is added
393
   * to the user interface.
394
   *
395
   * @param tab The tab editor that can trigger keyboard events.
396
   */
397
  private void initTabKeyEventListener( final FileEditorTab tab ) {
398
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
399
  }
400
401
  private void initTextChangeListener( final FileEditorTab tab ) {
402
    tab.addTextChangeListener(
403
        ( editor, oldValue, newValue ) -> {
404
          process( tab );
405
          scrollToParagraph( getCurrentParagraphIndex() );
406
        }
407
    );
408
  }
409
410
  private int getCurrentParagraphIndex() {
411
    return getActiveEditorPane().getCurrentParagraphIndex();
412
  }
413
414
  private void scrollToParagraph( final int id ) {
415
    scrollToParagraph( id, false );
416
  }
417
418
  /**
419
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
420
   *              exist.
421
   * @param force {@code true} means to force scrolling immediately, which
422
   *              should only be attempted when it is known that the document
423
   *              has been fully rendered. Otherwise the internal map of ID
424
   *              attributes will be incomplete and scrolling will flounder.
425
   */
426
  private void scrollToParagraph( final int id, final boolean force ) {
427
    synchronized( mMutex ) {
428
      final var previewPane = getPreviewPane();
429
      final var scrollPane = previewPane.getScrollPane();
430
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
431
432
      if( force ) {
433
        previewPane.scrollTo( approxId );
434
      }
435
      else {
436
        previewPane.tryScrollTo( approxId );
437
      }
438
439
      scrollPane.repaint();
440
    }
441
  }
442
443
  private void updateVariableNameInjector( final FileEditorTab tab ) {
444
    getVariableNameInjector().addListener( tab );
445
  }
446
447
  /**
448
   * Called whenever the preview pane becomes out of sync with the file editor
449
   * tab. This can be called when the text changes, the caret paragraph
450
   * changes,
451
   * or the file tab changes.
452
   *
453
   * @param tab The file editor tab that has been changed in some fashion.
454
   */
455
  private void process( final FileEditorTab tab ) {
456
    if( tab == null ) {
457
      return;
458
    }
459
460
    getPreviewPane().setPath( tab.getPath() );
461
462
    final Processor<String> processor = getProcessors().computeIfAbsent(
463
        tab, p -> createProcessor( tab )
464
    );
465
466
    try {
467
      processor.processChain( tab.getEditorText() );
468
    } catch( final Exception ex ) {
469
      error( ex );
470
    }
471
  }
472
473
  private void renderActiveTab() {
474
    process( getActiveFileEditorTab() );
475
  }
476
477
  /**
478
   * Called when a definition source is opened.
479
   *
480
   * @param path Path to the definition source that was opened.
481
   */
482
  private void openDefinitions( final Path path ) {
483
    try {
484
      final DefinitionSource ds = createDefinitionSource( path );
485
      setDefinitionSource( ds );
486
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
487
      getUserPreferences().save();
488
489
      final Tooltip tooltipPath = new Tooltip( path.toString() );
490
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
491
492
      final DefinitionPane pane = getDefinitionPane();
493
      pane.update( ds );
494
      pane.addTreeChangeHandler( mTreeHandler );
495
      pane.addKeyEventHandler( mDefinitionKeyHandler );
496
      pane.filenameProperty().setValue( path.getFileName().toString() );
497
      pane.setTooltip( tooltipPath );
498
499
      interpolateResolvedMap();
500
    } catch( final Exception e ) {
501
      error( e );
502
    }
503
  }
504
505
  private void exportDefinitions( final Path path ) {
506
    try {
507
      final DefinitionPane pane = getDefinitionPane();
508
      final TreeItem<String> root = pane.getTreeView().getRoot();
509
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
510
511
      if( problemChild == null ) {
512
        getDefinitionSource().getTreeAdapter().export( root, path );
513
        getNotifier().clear();
514
      }
515
      else {
516
        final String msg = get(
517
            "yaml.error.tree.form", problemChild.getValue() );
518
        getNotifier().notify( msg );
519
      }
520
    } catch( final Exception e ) {
521
      error( e );
522
    }
523
  }
524
525
  private void interpolateResolvedMap() {
526
    final Map<String, String> treeMap = getDefinitionPane().toMap();
527
    final Map<String, String> map = new HashMap<>( treeMap );
528
    MapInterpolator.interpolate( map );
529
530
    getResolvedMap().clear();
531
    getResolvedMap().putAll( map );
532
  }
533
534
  private void initDefinitionPane() {
535
    openDefinitions( getDefinitionPath() );
536
  }
537
538
  /**
539
   * Called when an exception occurs that warrants the user's attention.
540
   *
541
   * @param e The exception with a message that the user should know about.
542
   */
543
  private void error( final Exception e ) {
544
    getNotifier().notify( e );
545
  }
546
547
  //---- File actions -------------------------------------------------------
548
549
  /**
550
   * Called when an {@link Observable} instance has changed. This is called
551
   * by both the {@link Snitch} service and the notify service. The @link
552
   * Snitch} service can be called for different file types, including
553
   * {@link DefinitionSource} instances.
554
   *
555
   * @param observable The observed instance.
556
   * @param value      The noteworthy item.
557
   */
558
  @Override
559
  public void update( final Observable observable, final Object value ) {
560
    if( value != null ) {
561
      if( observable instanceof Snitch && value instanceof Path ) {
562
        updateSelectedTab();
563
      }
564
      else if( observable instanceof Notifier && value instanceof String ) {
565
        updateStatusBar( (String) value );
566
      }
567
    }
568
  }
569
570
  /**
571
   * Updates the status bar to show the given message.
572
   *
573
   * @param s The message to show in the status bar.
574
   */
575
  private void updateStatusBar( final String s ) {
576
    Platform.runLater(
577
        () -> {
578
          final int index = s.indexOf( '\n' );
579
          final String message = s.substring(
580
              0, index > 0 ? index : s.length() );
581
582
          getStatusBar().setText( message );
583
        }
584
    );
585
  }
586
587
  /**
588
   * Called when a file has been modified.
589
   */
590
  private void updateSelectedTab() {
591
    Platform.runLater(
592
        () -> {
593
          // Brute-force XSLT file reload by re-instantiating all processors.
594
          resetProcessors();
595
          renderActiveTab();
596
        }
597
    );
598
  }
599
600
  /**
601
   * After resetting the processors, they will refresh anew to be up-to-date
602
   * with the files (text and definition) currently loaded into the editor.
603
   */
604
  private void resetProcessors() {
605
    getProcessors().clear();
606
  }
607
608
  //---- File actions -------------------------------------------------------
609
610
  private void fileNew() {
611
    getFileEditorPane().newEditor();
612
  }
613
614
  private void fileOpen() {
615
    getFileEditorPane().openFileDialog();
616
  }
617
618
  private void fileClose() {
619
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
620
  }
621
622
  /**
623
   * TODO: Upon closing, first remove the tab change listeners. (There's no
624
   * need to re-render each tab when all are being closed.)
625
   */
626
  private void fileCloseAll() {
627
    getFileEditorPane().closeAllEditors();
628
  }
629
630
  private void fileSave() {
631
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
632
  }
633
634
  private void fileSaveAs() {
635
    final FileEditorTab editor = getActiveFileEditorTab();
636
    getFileEditorPane().saveEditorAs( editor );
637
    getProcessors().remove( editor );
638
639
    try {
640
      process( editor );
641
    } catch( final Exception ex ) {
642
      getNotifier().notify( ex );
643
    }
644
  }
645
646
  private void fileSaveAll() {
647
    getFileEditorPane().saveAllEditors();
648
  }
649
650
  private void fileExit() {
651
    final Window window = getWindow();
652
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
653
  }
654
655
  //---- Edit actions -------------------------------------------------------
656
657
  /**
658
   * Used to find text in the active file editor window.
659
   */
660
  private void editFind() {
661
    final TextField input = getFindTextField();
662
    getStatusBar().setGraphic( input );
663
    input.requestFocus();
664
  }
665
666
  public void editFindNext() {
667
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
668
  }
669
670
  public void editPreferences() {
671
    getUserPreferences().show();
672
  }
673
674
  //---- Insert actions -----------------------------------------------------
675
676
  /**
677
   * Delegates to the active editor to handle wrapping the current text
678
   * selection with leading and trailing strings.
679
   *
680
   * @param leading  The string to put before the selection.
681
   * @param trailing The string to put after the selection.
682
   */
683
  private void insertMarkdown(
684
      final String leading, final String trailing ) {
685
    getActiveEditorPane().surroundSelection( leading, trailing );
686
  }
687
688
  private void insertMarkdown(
689
      final String leading, final String trailing, final String hint ) {
690
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
691
  }
692
693
  //---- Help actions -------------------------------------------------------
694
695
  private void helpAbout() {
696
    final Alert alert = new Alert( AlertType.INFORMATION );
697
    alert.setTitle( get( "Dialog.about.title" ) );
698
    alert.setHeaderText( get( "Dialog.about.header" ) );
699
    alert.setContentText( get( "Dialog.about.content" ) );
700
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
701
    alert.initOwner( getWindow() );
702
703
    alert.showAndWait();
704
  }
705
706
  //---- Member creators ----------------------------------------------------
707
708
  /**
709
   * Factory to create processors that are suited to different file types.
710
   *
711
   * @param tab The tab that is subjected to processing.
712
   * @return A processor suited to the file type specified by the tab's path.
713
   */
714
  private Processor<String> createProcessor( final FileEditorTab tab ) {
715
    return createProcessorFactory().createProcessor( tab );
716
  }
717
718
  private ProcessorFactory createProcessorFactory() {
719
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
720
  }
721
722
  private HTMLPreviewPane createHTMLPreviewPane() {
723
    return new HTMLPreviewPane();
724
  }
725
726
  private DefinitionSource createDefaultDefinitionSource() {
727
    return new YamlDefinitionSource( getDefinitionPath() );
728
  }
729
730
  private DefinitionSource createDefinitionSource( final Path path ) {
731
    try {
732
      return createDefinitionFactory().createDefinitionSource( path );
733
    } catch( final Exception ex ) {
734
      error( ex );
735
      return createDefaultDefinitionSource();
736
    }
737
  }
738
739
  private TextField createFindTextField() {
740
    return new TextField();
741
  }
742
743
  private DefinitionFactory createDefinitionFactory() {
744
    return new DefinitionFactory();
745
  }
746
747
  private StatusBar createStatusBar() {
748
    return new StatusBar();
749
  }
750
751
  private Scene createScene() {
752
    final SplitPane splitPane = new SplitPane(
753
        getDefinitionPane().getNode(),
754
        getFileEditorPane().getNode(),
755
        getPreviewPane().getNode() );
756
757
    splitPane.setDividerPositions(
758
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
759
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
760
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
761
762
    getDefinitionPane().prefHeightProperty()
763
                       .bind( splitPane.heightProperty() );
764
765
    final BorderPane borderPane = new BorderPane();
766
    borderPane.setPrefSize( 1024, 800 );
767
    borderPane.setTop( createMenuBar() );
768
    borderPane.setBottom( getStatusBar() );
769
    borderPane.setCenter( splitPane );
770
771
    final VBox statusBar = new VBox();
772
    statusBar.setAlignment( Pos.BASELINE_CENTER );
773
    statusBar.getChildren().add( getLineNumberText() );
774
    getStatusBar().getRightItems().add( statusBar );
78
import org.apache.commons.lang3.SystemUtils;
79
import org.controlsfx.control.StatusBar;
80
import org.fxmisc.richtext.StyleClassedTextArea;
81
import org.reactfx.value.Val;
82
import org.xhtmlrenderer.util.XRLog;
83
84
import java.awt.*;
85
import java.awt.font.TextAttribute;
86
import java.io.FileInputStream;
87
import java.io.IOException;
88
import java.io.InputStream;
89
import java.net.URI;
90
import java.nio.file.Path;
91
import java.util.HashMap;
92
import java.util.Map;
93
import java.util.Observable;
94
import java.util.Observer;
95
import java.util.function.Function;
96
import java.util.prefs.Preferences;
97
98
import static com.scrivenvar.Constants.*;
99
import static com.scrivenvar.Messages.get;
100
import static com.scrivenvar.util.StageState.*;
101
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
102
import static java.awt.font.TextAttribute.LIGATURES;
103
import static java.awt.font.TextAttribute.LIGATURES_ON;
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
109
/**
110
 * Main window containing a tab pane in the center for file editors.
111
 *
112
 * @author Karl Tauber and White Magic Software, Ltd.
113
 */
114
public class MainWindow implements Observer {
115
  /**
116
   * The {@code OPTIONS} variable must be declared before all other variables
117
   * to prevent subsequent initializations from failing due to missing user
118
   * preferences.
119
   */
120
  private final static Options OPTIONS = Services.load( Options.class );
121
  private final static Snitch SNITCH = Services.load( Snitch.class );
122
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
123
124
  private final Scene mScene;
125
  private final StatusBar mStatusBar;
126
  private final Text mLineNumberText;
127
  private final TextField mFindTextField;
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
    mStatusBar = createStatusBar();
206
    mLineNumberText = createLineNumberText();
207
    mFindTextField = createFindTextField();
208
    mScene = createScene();
209
210
    System.getProperties()
211
          .setProperty( "xr.util-logging.loggingEnabled", "true" );
212
    XRLog.setLoggingEnabled( true );
213
214
    initLayout();
215
    initFindInput();
216
    initSnitch();
217
    initDefinitionListener();
218
    initTabAddedListener();
219
    initTabChangedListener();
220
    initPreferences();
221
    initVariableNameInjector();
222
223
    NOTIFIER.addObserver( this );
224
  }
225
226
  private void initLayout() {
227
    final Scene appScene = getScene();
228
229
    appScene.getStylesheets().add( STYLESHEET_SCENE );
230
231
    // TODO: Apply an XML syntax highlighting for XML files.
232
//    appScene.getStylesheets().add( STYLESHEET_XML );
233
    appScene.windowProperty().addListener(
234
        ( observable, oldWindow, newWindow ) ->
235
            newWindow.setOnCloseRequest(
236
                e -> {
237
                  if( !getFileEditorPane().closeAllEditors() ) {
238
                    e.consume();
239
                  }
240
                }
241
            )
242
    );
243
  }
244
245
  /**
246
   * Initialize the find input text field to listen on F3, ENTER, and
247
   * ESCAPE key presses.
248
   */
249
  private void initFindInput() {
250
    final TextField input = getFindTextField();
251
252
    input.setOnKeyPressed( ( KeyEvent event ) -> {
253
      switch( event.getCode() ) {
254
        case F3:
255
        case ENTER:
256
          editFindNext();
257
          break;
258
        case F:
259
          if( !event.isControlDown() ) {
260
            break;
261
          }
262
        case ESCAPE:
263
          getStatusBar().setGraphic( null );
264
          getActiveFileEditorTab().getEditorPane().requestFocus();
265
          break;
266
      }
267
    } );
268
269
    // Remove when the input field loses focus.
270
    input.focusedProperty().addListener(
271
        ( focused, oldFocus, newFocus ) -> {
272
          if( !newFocus ) {
273
            getStatusBar().setGraphic( null );
274
          }
275
        }
276
    );
277
  }
278
279
  /**
280
   * Watch for changes to external files. In particular, this awaits
281
   * modifications to any XSL files associated with XML files being edited.
282
   * When
283
   * an XSL file is modified (external to the application), the snitch's ears
284
   * perk up and the file is reloaded. This keeps the XSL transformation up to
285
   * date with what's on the file system.
286
   */
287
  private void initSnitch() {
288
    SNITCH.addObserver( this );
289
  }
290
291
  /**
292
   * Listen for {@link FileEditorTabPane} to receive open definition file
293
   * event.
294
   */
295
  private void initDefinitionListener() {
296
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
297
        ( final ObservableValue<? extends Path> file,
298
          final Path oldPath, final Path newPath ) -> {
299
          // Indirectly refresh the resolved map.
300
          resetProcessors();
301
302
          openDefinitions( newPath );
303
304
          // Will create new processors and therefore a new resolved map.
305
          renderActiveTab();
306
        }
307
    );
308
  }
309
310
  /**
311
   * When tabs are added, hook the various change listeners onto the new
312
   * tab sothat the preview pane refreshes as necessary.
313
   */
314
  private void initTabAddedListener() {
315
    final FileEditorTabPane editorPane = getFileEditorPane();
316
317
    // Make sure the text processor kicks off when new files are opened.
318
    final ObservableList<Tab> tabs = editorPane.getTabs();
319
320
    // Update the preview pane on tab changes.
321
    tabs.addListener(
322
        ( final Change<? extends Tab> change ) -> {
323
          while( change.next() ) {
324
            if( change.wasAdded() ) {
325
              // Multiple tabs can be added simultaneously.
326
              for( final Tab newTab : change.getAddedSubList() ) {
327
                final FileEditorTab tab = (FileEditorTab) newTab;
328
329
                initTextChangeListener( tab );
330
                initTabKeyEventListener( tab );
331
                initScrollEventListener( tab );
332
//              initSyntaxListener( tab );
333
              }
334
            }
335
          }
336
        }
337
    );
338
  }
339
340
  private void initScrollEventListener( final FileEditorTab tab ) {
341
    final var scrollPane = tab.getEditorPane().getScrollPane();
342
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
343
344
    // Before the drag handler can be attached, the scroll bar for the
345
    // text editor pane must be visible.
346
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
347
        Platform.runLater( () -> {
348
          if( newShow ) {
349
            final var handler = new ScrollEventHandler( scrollPane, scrollBar );
350
            handler.enabledProperty().bind( tab.selectedProperty() );
351
          }
352
        } );
353
354
    Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
355
       .flatMap( Window::showingProperty )
356
       .addListener( listener );
357
  }
358
359
  /**
360
   * Listen for new tab selection events.
361
   */
362
  private void initTabChangedListener() {
363
    final FileEditorTabPane editorPane = getFileEditorPane();
364
365
    // Update the preview pane changing tabs.
366
    editorPane.addTabSelectionListener(
367
        ( tabPane, oldTab, newTab ) -> {
368
          // If there was no old tab, then this is a first time load, which
369
          // can be ignored.
370
          if( oldTab != null ) {
371
            if( newTab != null ) {
372
              final FileEditorTab tab = (FileEditorTab) newTab;
373
              updateVariableNameInjector( tab );
374
              process( tab );
375
            }
376
          }
377
        }
378
    );
379
  }
380
381
  /**
382
   * Reloads the preferences from the previous session.
383
   */
384
  private void initPreferences() {
385
    initDefinitionPane();
386
    getFileEditorPane().initPreferences();
387
  }
388
389
  private void initVariableNameInjector() {
390
    updateVariableNameInjector( getActiveFileEditorTab() );
391
  }
392
393
  /**
394
   * Ensure that the keyboard events are received when a new tab is added
395
   * to the user interface.
396
   *
397
   * @param tab The tab editor that can trigger keyboard events.
398
   */
399
  private void initTabKeyEventListener( final FileEditorTab tab ) {
400
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
401
  }
402
403
  private void initTextChangeListener( final FileEditorTab tab ) {
404
    tab.addTextChangeListener(
405
        ( editor, oldValue, newValue ) -> {
406
          process( tab );
407
          scrollToParagraph( getCurrentParagraphIndex() );
408
        }
409
    );
410
  }
411
412
  private int getCurrentParagraphIndex() {
413
    return getActiveEditorPane().getCurrentParagraphIndex();
414
  }
415
416
  private void scrollToParagraph( final int id ) {
417
    scrollToParagraph( id, false );
418
  }
419
420
  /**
421
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
422
   *              exist.
423
   * @param force {@code true} means to force scrolling immediately, which
424
   *              should only be attempted when it is known that the document
425
   *              has been fully rendered. Otherwise the internal map of ID
426
   *              attributes will be incomplete and scrolling will flounder.
427
   */
428
  private void scrollToParagraph( final int id, final boolean force ) {
429
    synchronized( mMutex ) {
430
      final var previewPane = getPreviewPane();
431
      final var scrollPane = previewPane.getScrollPane();
432
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
433
434
      if( force ) {
435
        previewPane.scrollTo( approxId );
436
      }
437
      else {
438
        previewPane.tryScrollTo( approxId );
439
      }
440
441
      scrollPane.repaint();
442
    }
443
  }
444
445
  private void updateVariableNameInjector( final FileEditorTab tab ) {
446
    getVariableNameInjector().addListener( tab );
447
  }
448
449
  /**
450
   * Called whenever the preview pane becomes out of sync with the file editor
451
   * tab. This can be called when the text changes, the caret paragraph
452
   * changes,
453
   * or the file tab changes.
454
   *
455
   * @param tab The file editor tab that has been changed in some fashion.
456
   */
457
  private void process( final FileEditorTab tab ) {
458
    if( tab == null ) {
459
      return;
460
    }
461
462
    getPreviewPane().setPath( tab.getPath() );
463
464
    final Processor<String> processor = getProcessors().computeIfAbsent(
465
        tab, p -> createProcessor( tab )
466
    );
467
468
    try {
469
      processor.processChain( tab.getEditorText() );
470
    } catch( final Exception ex ) {
471
      error( ex );
472
    }
473
  }
474
475
  private void renderActiveTab() {
476
    process( getActiveFileEditorTab() );
477
  }
478
479
  /**
480
   * Called when a definition source is opened.
481
   *
482
   * @param path Path to the definition source that was opened.
483
   */
484
  private void openDefinitions( final Path path ) {
485
    try {
486
      final DefinitionSource ds = createDefinitionSource( path );
487
      setDefinitionSource( ds );
488
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
489
      getUserPreferences().save();
490
491
      final Tooltip tooltipPath = new Tooltip( path.toString() );
492
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
493
494
      final DefinitionPane pane = getDefinitionPane();
495
      pane.update( ds );
496
      pane.addTreeChangeHandler( mTreeHandler );
497
      pane.addKeyEventHandler( mDefinitionKeyHandler );
498
      pane.filenameProperty().setValue( path.getFileName().toString() );
499
      pane.setTooltip( tooltipPath );
500
501
      interpolateResolvedMap();
502
    } catch( final Exception e ) {
503
      error( e );
504
    }
505
  }
506
507
  private void exportDefinitions( final Path path ) {
508
    try {
509
      final DefinitionPane pane = getDefinitionPane();
510
      final TreeItem<String> root = pane.getTreeView().getRoot();
511
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
512
513
      if( problemChild == null ) {
514
        getDefinitionSource().getTreeAdapter().export( root, path );
515
        getNotifier().clear();
516
      }
517
      else {
518
        final String msg = get(
519
            "yaml.error.tree.form", problemChild.getValue() );
520
        getNotifier().notify( msg );
521
      }
522
    } catch( final Exception e ) {
523
      error( e );
524
    }
525
  }
526
527
  private void interpolateResolvedMap() {
528
    final Map<String, String> treeMap = getDefinitionPane().toMap();
529
    final Map<String, String> map = new HashMap<>( treeMap );
530
    MapInterpolator.interpolate( map );
531
532
    getResolvedMap().clear();
533
    getResolvedMap().putAll( map );
534
  }
535
536
  private void initDefinitionPane() {
537
    openDefinitions( getDefinitionPath() );
538
  }
539
540
  /**
541
   * Called when an exception occurs that warrants the user's attention.
542
   *
543
   * @param e The exception with a message that the user should know about.
544
   */
545
  private void error( final Exception e ) {
546
    getNotifier().notify( e );
547
  }
548
549
  //---- File actions -------------------------------------------------------
550
551
  /**
552
   * Called when an {@link Observable} instance has changed. This is called
553
   * by both the {@link Snitch} service and the notify service. The @link
554
   * Snitch} service can be called for different file types, including
555
   * {@link DefinitionSource} instances.
556
   *
557
   * @param observable The observed instance.
558
   * @param value      The noteworthy item.
559
   */
560
  @Override
561
  public void update( final Observable observable, final Object value ) {
562
    if( value != null ) {
563
      if( observable instanceof Snitch && value instanceof Path ) {
564
        updateSelectedTab();
565
      }
566
      else if( observable instanceof Notifier && value instanceof String ) {
567
        updateStatusBar( (String) value );
568
      }
569
    }
570
  }
571
572
  /**
573
   * Updates the status bar to show the given message.
574
   *
575
   * @param s The message to show in the status bar.
576
   */
577
  private void updateStatusBar( final String s ) {
578
    Platform.runLater(
579
        () -> {
580
          final int index = s.indexOf( '\n' );
581
          final String message = s.substring(
582
              0, index > 0 ? index : s.length() );
583
584
          getStatusBar().setText( message );
585
        }
586
    );
587
  }
588
589
  /**
590
   * Called when a file has been modified.
591
   */
592
  private void updateSelectedTab() {
593
    Platform.runLater(
594
        () -> {
595
          // Brute-force XSLT file reload by re-instantiating all processors.
596
          resetProcessors();
597
          renderActiveTab();
598
        }
599
    );
600
  }
601
602
  /**
603
   * After resetting the processors, they will refresh anew to be up-to-date
604
   * with the files (text and definition) currently loaded into the editor.
605
   */
606
  private void resetProcessors() {
607
    getProcessors().clear();
608
  }
609
610
  //---- File actions -------------------------------------------------------
611
612
  private void fileNew() {
613
    getFileEditorPane().newEditor();
614
  }
615
616
  private void fileOpen() {
617
    getFileEditorPane().openFileDialog();
618
  }
619
620
  private void fileClose() {
621
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
622
  }
623
624
  /**
625
   * TODO: Upon closing, first remove the tab change listeners. (There's no
626
   * need to re-render each tab when all are being closed.)
627
   */
628
  private void fileCloseAll() {
629
    getFileEditorPane().closeAllEditors();
630
  }
631
632
  private void fileSave() {
633
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
634
  }
635
636
  private void fileSaveAs() {
637
    final FileEditorTab editor = getActiveFileEditorTab();
638
    getFileEditorPane().saveEditorAs( editor );
639
    getProcessors().remove( editor );
640
641
    try {
642
      process( editor );
643
    } catch( final Exception ex ) {
644
      getNotifier().notify( ex );
645
    }
646
  }
647
648
  private void fileSaveAll() {
649
    getFileEditorPane().saveAllEditors();
650
  }
651
652
  private void fileExit() {
653
    final Window window = getWindow();
654
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
655
  }
656
657
  //---- Edit actions -------------------------------------------------------
658
659
  /**
660
   * Used to find text in the active file editor window.
661
   */
662
  private void editFind() {
663
    final TextField input = getFindTextField();
664
    getStatusBar().setGraphic( input );
665
    input.requestFocus();
666
  }
667
668
  public void editFindNext() {
669
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
670
  }
671
672
  public void editPreferences() {
673
    getUserPreferences().show();
674
  }
675
676
  //---- Insert actions -----------------------------------------------------
677
678
  /**
679
   * Delegates to the active editor to handle wrapping the current text
680
   * selection with leading and trailing strings.
681
   *
682
   * @param leading  The string to put before the selection.
683
   * @param trailing The string to put after the selection.
684
   */
685
  private void insertMarkdown(
686
      final String leading, final String trailing ) {
687
    getActiveEditorPane().surroundSelection( leading, trailing );
688
  }
689
690
  private void insertMarkdown(
691
      final String leading, final String trailing, final String hint ) {
692
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
693
  }
694
695
  //---- Help actions -------------------------------------------------------
696
697
  private void helpAbout() {
698
    final Alert alert = new Alert( AlertType.INFORMATION );
699
    alert.setTitle( get( "Dialog.about.title" ) );
700
    alert.setHeaderText( get( "Dialog.about.header" ) );
701
    alert.setContentText( get( "Dialog.about.content" ) );
702
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
703
    alert.initOwner( getWindow() );
704
705
    alert.showAndWait();
706
  }
707
708
  //---- Member creators ----------------------------------------------------
709
710
  /**
711
   * Factory to create processors that are suited to different file types.
712
   *
713
   * @param tab The tab that is subjected to processing.
714
   * @return A processor suited to the file type specified by the tab's path.
715
   */
716
  private Processor<String> createProcessor( final FileEditorTab tab ) {
717
    return createProcessorFactory().createProcessor( tab );
718
  }
719
720
  private ProcessorFactory createProcessorFactory() {
721
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
722
  }
723
724
  private HTMLPreviewPane createHTMLPreviewPane() {
725
    return new HTMLPreviewPane();
726
  }
727
728
  private DefinitionSource createDefaultDefinitionSource() {
729
    return new YamlDefinitionSource( getDefinitionPath() );
730
  }
731
732
  private DefinitionSource createDefinitionSource( final Path path ) {
733
    try {
734
      return createDefinitionFactory().createDefinitionSource( path );
735
    } catch( final Exception ex ) {
736
      error( ex );
737
      return createDefaultDefinitionSource();
738
    }
739
  }
740
741
  private TextField createFindTextField() {
742
    return new TextField();
743
  }
744
745
  private DefinitionFactory createDefinitionFactory() {
746
    return new DefinitionFactory();
747
  }
748
749
  private StatusBar createStatusBar() {
750
    return new StatusBar();
751
  }
752
753
  private Scene createScene() {
754
    final SplitPane splitPane = new SplitPane(
755
        getDefinitionPane().getNode(),
756
        getFileEditorPane().getNode(),
757
        getPreviewPane().getNode() );
758
759
    splitPane.setDividerPositions(
760
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
761
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
762
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
763
764
    getDefinitionPane().prefHeightProperty()
765
                       .bind( splitPane.heightProperty() );
766
767
    final BorderPane borderPane = new BorderPane();
768
    borderPane.setPrefSize( 1024, 800 );
769
    borderPane.setTop( createMenuBar() );
770
    borderPane.setBottom( getStatusBar() );
771
    borderPane.setCenter( splitPane );
772
773
    final VBox statusBar = new VBox();
774
    statusBar.setAlignment( Pos.BASELINE_CENTER );
775
    statusBar.getChildren().add( getLineNumberText() );
776
    getStatusBar().getRightItems().add( statusBar );
777
778
    // Force preview pane refresh on Windows.
779
    splitPane.getDividers().get( 1 ).positionProperty().addListener(
780
        ( l, oValue, nValue ) -> Platform.runLater(
781
            () -> {
782
              if( SystemUtils.IS_OS_WINDOWS ) {
783
                getPreviewPane().getScrollPane().repaint();
784
              }
785
            }
786
        )
787
    );
775788
776789
    return new Scene( borderPane );
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
138138
139139
    mRenderer.addDocumentListener( mDocumentHandler );
140
    setStyle("-fx-background-color: white;");
140141
  }
141142