Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
1919
2020
dependencies {
21
  // JavaFX
2122
  implementation 'org.reactfx:reactfx:1.4.1'
2223
  implementation 'org.controlsfx:controlsfx:11.0.1'
2324
  implementation 'org.fxmisc.richtext:richtextfx:0.10.5'
2425
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
2526
  implementation 'com.miglayout:miglayout-javafx:5.2'
2627
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.6.0'
28
  implementation 'de.jensd:fontawesomefx-commons:11.0'
29
  implementation 'de.jensd:fontawesomefx-fontawesome:4.7.0-11'
30
31
  // Markdown
2732
  implementation 'com.vladsch.flexmark:flexmark:0.62.2'
2833
  implementation 'com.vladsch.flexmark:flexmark-ext-definition:0.62.2'
2934
  implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.2'
3035
  implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.2'
3136
  implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.2'
3237
  implementation 'com.vladsch.flexmark:flexmark-ext-typographic:0.62.2'
38
39
  // YAML
3340
  implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0'
3441
  implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.0'
3542
  implementation 'com.fasterxml.jackson.core:jackson-annotations:2.11.0'
3643
  implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.0'
37
  implementation 'org.ahocorasick:ahocorasick:0.4.0'
3844
  implementation 'org.yaml:snakeyaml:1.26'
45
46
  // XML and XSL
3947
  implementation 'com.ximpleware:vtd-xml:2.13.4'
4048
  implementation 'net.sf.saxon:Saxon-HE:10.1'
49
50
  // HTML parsing and rendering
51
  implementation 'org.jsoup:jsoup:1.13.1'
52
  implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.20'
53
54
  // R
55
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
56
57
  // SVG
58
  implementation 'org.apache.xmlgraphics:batik-anim:1.13'
59
  implementation 'org.apache.xmlgraphics:batik-awt-util:1.13'
60
  implementation 'org.apache.xmlgraphics:batik-bridge:1.13'
61
  implementation 'org.apache.xmlgraphics:batik-css:1.13'
62
  implementation 'org.apache.xmlgraphics:batik-dom:1.13'
63
  implementation 'org.apache.xmlgraphics:batik-ext:1.13'
64
  implementation 'org.apache.xmlgraphics:batik-gvt:1.13'
65
  implementation 'org.apache.xmlgraphics:batik-parser:1.13'
66
  implementation 'org.apache.xmlgraphics:batik-script:1.13'
67
  implementation 'org.apache.xmlgraphics:batik-svg-dom:1.13'
68
  implementation 'org.apache.xmlgraphics:batik-svggen:1.13'
69
  implementation 'org.apache.xmlgraphics:batik-transcoder:1.13'
70
  implementation 'org.apache.xmlgraphics:batik-util:1.13'
71
  implementation 'org.apache.xmlgraphics:batik-xml:1.13'
72
73
  // Misc.
74
  implementation 'org.ahocorasick:ahocorasick:0.4.0'
4175
  implementation 'org.apache.commons:commons-configuration2:2.7'
4276
  implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
43
  implementation 'de.jensd:fontawesomefx-commons:11.0'
44
  implementation 'de.jensd:fontawesomefx-fontawesome:4.7.0-11'
45
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
46
  implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.20'
47
  implementation 'org.jsoup:jsoup:1.13.1'
48
  implementation 'org.apache.xmlgraphics:batik-all:1.13'
4977
5078
  def os = ['win', 'linux', 'mac']
...
78106
def propertiesFile = new File("src/main/resources/com/${applicationName}/app.properties")
79107
propertiesFile.write("application.version=${version}")
108
109
//sourceSets {
110
//  main {
111
//    resources {
112
//      srcDir 'resources'
113
//    }
114
//  }
115
//}
80116
81117
jar {
M src/main/java/com/scrivenvar/Main.java
3131
import com.scrivenvar.service.Options;
3232
import com.scrivenvar.service.Snitch;
33
import com.scrivenvar.service.events.Notifier;
34
import com.scrivenvar.util.ResourceWalker;
3335
import com.scrivenvar.util.StageState;
3436
import javafx.application.Application;
3537
import javafx.scene.Scene;
3638
import javafx.scene.image.Image;
3739
import javafx.stage.Stage;
3840
41
import java.awt.*;
42
import java.io.FileInputStream;
43
import java.io.IOException;
44
import java.io.InputStream;
45
import java.net.URI;
46
import java.util.Map;
3947
import java.util.logging.LogManager;
4048
4149
import static com.scrivenvar.Constants.*;
4250
import static com.scrivenvar.Messages.get;
51
import static java.awt.font.TextAttribute.LIGATURES;
52
import static java.awt.font.TextAttribute.LIGATURES_ON;
4353
4454
/**
...
5565
  }
5666
67
  private final static Notifier sNotifier = Services.load( Notifier.class );
5768
  private final Options mOptions = Services.load( Options.class );
5869
  private final Snitch mSnitch = Services.load( Snitch.class );
...
7081
  public static void main( final String[] args ) {
7182
    initPreferences();
83
    initFonts();
7284
    launch( args );
7385
  }
...
8597
8698
    stage.show();
99
  }
100
101
  /**
102
   * This needs to run before the windowing system kicks in, otherwise the
103
   * fonts will not be found.
104
   */
105
  @SuppressWarnings({"rawtypes", "unchecked"})
106
  private static void initFonts() {
107
    final var ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
108
109
    try {
110
      ResourceWalker.walk(
111
          "/fonts", path -> {
112
            final var uri = path.toUri();
113
            final var filename = path.toString();
114
115
            try( final var is = openFont( uri, filename ) ) {
116
              final var font = Font.createFont( Font.TRUETYPE_FONT, is );
117
              final Map attributes = font.getAttributes();
118
              attributes.put( LIGATURES, LIGATURES_ON );
119
              ge.registerFont( font.deriveFont( attributes ) );
120
            } catch( final Exception e ) {
121
              getNotifier().notify( e );
122
            }
123
          }
124
      );
125
    } catch( final Exception e ) {
126
      getNotifier().notify( e );
127
    }
128
  }
129
130
  private static InputStream openFont( final URI uri, final String filename )
131
      throws IOException {
132
    return uri.getScheme().equals( "jar" )
133
        ? Main.class.getResourceAsStream( filename )
134
        : new FileInputStream( filename );
87135
  }
88136
...
143191
  private Options getOptions() {
144192
    return mOptions;
193
  }
194
195
  private static Notifier getNotifier() {
196
    return sNotifier;
145197
  }
146198
M src/main/java/com/scrivenvar/MainWindow.java
4646
import com.scrivenvar.util.ActionBuilder;
4747
import com.scrivenvar.util.ActionUtils;
48
import javafx.application.Platform;
49
import javafx.beans.binding.Bindings;
50
import javafx.beans.binding.BooleanBinding;
51
import javafx.beans.property.BooleanProperty;
52
import javafx.beans.property.SimpleBooleanProperty;
53
import javafx.beans.value.ChangeListener;
54
import javafx.beans.value.ObservableBooleanValue;
55
import javafx.beans.value.ObservableValue;
56
import javafx.collections.ListChangeListener.Change;
57
import javafx.collections.ObservableList;
58
import javafx.event.Event;
59
import javafx.event.EventHandler;
60
import javafx.geometry.Pos;
61
import javafx.scene.Node;
62
import javafx.scene.Scene;
63
import javafx.scene.control.*;
64
import javafx.scene.control.Alert.AlertType;
65
import javafx.scene.image.Image;
66
import javafx.scene.image.ImageView;
67
import javafx.scene.input.KeyEvent;
68
import javafx.scene.layout.BorderPane;
69
import javafx.scene.layout.VBox;
70
import javafx.scene.text.Text;
71
import javafx.stage.Window;
72
import javafx.stage.WindowEvent;
73
import javafx.util.Duration;
74
import org.controlsfx.control.StatusBar;
75
import org.fxmisc.richtext.StyleClassedTextArea;
76
import org.reactfx.value.Val;
77
78
import java.nio.file.Path;
79
import java.util.HashMap;
80
import java.util.Map;
81
import java.util.Observable;
82
import java.util.Observer;
83
import java.util.function.Function;
84
import java.util.prefs.Preferences;
85
86
import static com.scrivenvar.Constants.*;
87
import static com.scrivenvar.Messages.get;
88
import static com.scrivenvar.util.StageState.*;
89
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
90
import static javafx.event.Event.fireEvent;
91
import static javafx.scene.input.KeyCode.ENTER;
92
import static javafx.scene.input.KeyCode.TAB;
93
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
94
95
/**
96
 * Main window containing a tab pane in the center for file editors.
97
 *
98
 * @author Karl Tauber and White Magic Software, Ltd.
99
 */
100
public class MainWindow implements Observer {
101
  /**
102
   * The {@code OPTIONS} variable must be declared before all other variables
103
   * to prevent subsequent initializations from failing due to missing user
104
   * preferences.
105
   */
106
  private final static Options OPTIONS = Services.load( Options.class );
107
  private final static Snitch SNITCH = Services.load( Snitch.class );
108
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
109
110
  private final Scene mScene;
111
  private final StatusBar mStatusBar;
112
  private final Text mLineNumberText;
113
  private final TextField mFindTextField;
114
115
  private final Object mMutex = new Object();
116
117
  /**
118
   * Prevents re-instantiation of processing classes.
119
   */
120
  private final Map<FileEditorTab, Processor<String>> mProcessors =
121
      new HashMap<>();
122
123
  private final Map<String, String> mResolvedMap =
124
      new HashMap<>( DEFAULT_MAP_SIZE );
125
126
  /**
127
   * Called when the definition data is changed.
128
   */
129
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
130
      mTreeHandler = event -> {
131
    exportDefinitions( getDefinitionPath() );
132
    interpolateResolvedMap();
133
    renderActiveTab();
134
  };
135
136
  /**
137
   * Called to switch to the definition pane when the user presses the TAB key.
138
   */
139
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
140
      (EventHandler<KeyEvent>) event -> {
141
        if( event.getCode() == TAB ) {
142
          getDefinitionPane().requestFocus();
143
          event.consume();
144
        }
145
      };
146
147
  /**
148
   * Called to inject the selected item when the user presses ENTER in the
149
   * definition pane.
150
   */
151
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
152
      event -> {
153
        if( event.getCode() == ENTER ) {
154
          getVariableNameInjector().injectSelectedItem();
155
        }
156
      };
157
158
  private final ChangeListener<Integer> mCaretPositionListener =
159
      ( observable, oldPosition, newPosition ) -> {
160
        final FileEditorTab tab = getActiveFileEditorTab();
161
        final EditorPane pane = tab.getEditorPane();
162
        final StyleClassedTextArea editor = pane.getEditor();
163
164
        getLineNumberText().setText(
165
            get( STATUS_BAR_LINE,
166
                 editor.getCurrentParagraph() + 1,
167
                 editor.getParagraphs().size(),
168
                 editor.getCaretPosition()
169
            )
170
        );
171
      };
172
173
  private final ChangeListener<Integer> mCaretParagraphListener =
174
      ( observable, oldIndex, newIndex ) ->
175
          scrollToParagraph( newIndex, true );
176
177
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
178
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
179
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
180
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
181
      mCaretPositionListener,
182
      mCaretParagraphListener );
183
184
  /**
185
   * Listens on the definition pane for double-click events.
186
   */
187
  private final VariableNameInjector mVariableNameInjector
188
      = new VariableNameInjector( mDefinitionPane );
189
190
  public MainWindow() {
191
    mStatusBar = createStatusBar();
192
    mLineNumberText = createLineNumberText();
193
    mFindTextField = createFindTextField();
194
    mScene = createScene();
195
196
    initLayout();
197
    initFindInput();
198
    initSnitch();
199
    initDefinitionListener();
200
    initTabAddedListener();
201
    initTabChangedListener();
202
    initPreferences();
203
    initVariableNameInjector();
204
205
    NOTIFIER.addObserver( this );
206
  }
207
208
  private void initLayout() {
209
    final Scene appScene = getScene();
210
211
    appScene.getStylesheets().add( STYLESHEET_SCENE );
212
213
    // TODO: Apply an XML syntax highlighting for XML files.
214
//    appScene.getStylesheets().add( STYLESHEET_XML );
215
    appScene.windowProperty().addListener(
216
        ( observable, oldWindow, newWindow ) ->
217
            newWindow.setOnCloseRequest(
218
                e -> {
219
                  if( !getFileEditorPane().closeAllEditors() ) {
220
                    e.consume();
221
                  }
222
                }
223
            )
224
    );
225
  }
226
227
  /**
228
   * Initialize the find input text field to listen on F3, ENTER, and
229
   * ESCAPE key presses.
230
   */
231
  private void initFindInput() {
232
    final TextField input = getFindTextField();
233
234
    input.setOnKeyPressed( ( KeyEvent event ) -> {
235
      switch( event.getCode() ) {
236
        case F3:
237
        case ENTER:
238
          editFindNext();
239
          break;
240
        case F:
241
          if( !event.isControlDown() ) {
242
            break;
243
          }
244
        case ESCAPE:
245
          getStatusBar().setGraphic( null );
246
          getActiveFileEditorTab().getEditorPane().requestFocus();
247
          break;
248
      }
249
    } );
250
251
    // Remove when the input field loses focus.
252
    input.focusedProperty().addListener(
253
        ( focused, oldFocus, newFocus ) -> {
254
          if( !newFocus ) {
255
            getStatusBar().setGraphic( null );
256
          }
257
        }
258
    );
259
  }
260
261
  /**
262
   * Watch for changes to external files. In particular, this awaits
263
   * modifications to any XSL files associated with XML files being edited.
264
   * When
265
   * an XSL file is modified (external to the application), the snitch's ears
266
   * perk up and the file is reloaded. This keeps the XSL transformation up to
267
   * date with what's on the file system.
268
   */
269
  private void initSnitch() {
270
    SNITCH.addObserver( this );
271
  }
272
273
  /**
274
   * Listen for {@link FileEditorTabPane} to receive open definition file
275
   * event.
276
   */
277
  private void initDefinitionListener() {
278
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
279
        ( final ObservableValue<? extends Path> file,
280
          final Path oldPath, final Path newPath ) -> {
281
          // Indirectly refresh the resolved map.
282
          resetProcessors();
283
284
          openDefinitions( newPath );
285
286
          // Will create new processors and therefore a new resolved map.
287
          renderActiveTab();
288
        }
289
    );
290
  }
291
292
  /**
293
   * When tabs are added, hook the various change listeners onto the new
294
   * tab sothat the preview pane refreshes as necessary.
295
   */
296
  private void initTabAddedListener() {
297
    final FileEditorTabPane editorPane = getFileEditorPane();
298
299
    // Make sure the text processor kicks off when new files are opened.
300
    final ObservableList<Tab> tabs = editorPane.getTabs();
301
302
    // Update the preview pane on tab changes.
303
    tabs.addListener(
304
        ( final Change<? extends Tab> change ) -> {
305
          while( change.next() ) {
306
            if( change.wasAdded() ) {
307
              // Multiple tabs can be added simultaneously.
308
              for( final Tab newTab : change.getAddedSubList() ) {
309
                final FileEditorTab tab = (FileEditorTab) newTab;
310
311
                initTextChangeListener( tab );
312
                initTabKeyEventListener( tab );
313
                initScrollEventListener( tab );
314
//              initSyntaxListener( tab );
315
              }
316
            }
317
          }
318
        }
319
    );
320
  }
321
322
  private void initScrollEventListener( final FileEditorTab tab ) {
323
    final var scrollPane = tab.getEditorPane().getScrollPane();
324
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
325
326
    // Before the drag handler can be attached, the scroll bar for the
327
    // text editor pane must be visible.
328
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
329
        Platform.runLater( () -> {
330
          if( newShow ) {
331
            final var handler = new ScrollEventHandler( scrollPane, scrollBar );
332
            handler.enabledProperty().bind( tab.selectedProperty() );
333
          }
334
        } );
335
336
    Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
337
       .flatMap( Window::showingProperty )
338
       .addListener( listener );
339
  }
340
341
  /**
342
   * Listen for new tab selection events.
343
   */
344
  private void initTabChangedListener() {
345
    final FileEditorTabPane editorPane = getFileEditorPane();
346
347
    // Update the preview pane changing tabs.
348
    editorPane.addTabSelectionListener(
349
        ( tabPane, oldTab, newTab ) -> {
350
          // If there was no old tab, then this is a first time load, which
351
          // can be ignored.
352
          if( oldTab != null ) {
353
            if( newTab != null ) {
354
              final FileEditorTab tab = (FileEditorTab) newTab;
355
              updateVariableNameInjector( tab );
356
              process( tab );
357
            }
358
          }
359
        }
360
    );
361
  }
362
363
  /**
364
   * Reloads the preferences from the previous session.
365
   */
366
  private void initPreferences() {
367
    initDefinitionPane();
368
    getFileEditorPane().initPreferences();
369
  }
370
371
  private void initVariableNameInjector() {
372
    updateVariableNameInjector( getActiveFileEditorTab() );
373
  }
374
375
  /**
376
   * Ensure that the keyboard events are received when a new tab is added
377
   * to the user interface.
378
   *
379
   * @param tab The tab editor that can trigger keyboard events.
380
   */
381
  private void initTabKeyEventListener( final FileEditorTab tab ) {
382
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
383
  }
384
385
  private void initTextChangeListener( final FileEditorTab tab ) {
386
    tab.addTextChangeListener(
387
        ( editor, oldValue, newValue ) -> {
388
          process( tab );
389
          scrollToParagraph( getCurrentParagraphIndex() );
390
        }
391
    );
392
  }
393
394
  private int getCurrentParagraphIndex() {
395
    return getActiveEditorPane().getCurrentParagraphIndex();
396
  }
397
398
  private void scrollToParagraph( final int id ) {
399
    scrollToParagraph( id, false );
400
  }
401
402
  /**
403
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
404
   *              exist.
405
   * @param force {@code true} means to force scrolling immediately, which
406
   *              should only be attempted when it is known that the document
407
   *              has been fully rendered. Otherwise the internal map of ID
408
   *              attributes will be incomplete and scrolling will flounder.
409
   */
410
  private void scrollToParagraph( final int id, final boolean force ) {
411
    synchronized( mMutex ) {
412
      final var previewPane = getPreviewPane();
413
      final var scrollPane = previewPane.getScrollPane();
414
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
415
416
      if( force ) {
417
        previewPane.scrollTo( approxId );
418
      }
419
      else {
420
        previewPane.tryScrollTo( approxId );
421
      }
422
423
      scrollPane.repaint();
424
    }
425
  }
426
427
  private void updateVariableNameInjector( final FileEditorTab tab ) {
428
    getVariableNameInjector().addListener( tab );
429
  }
430
431
  /**
432
   * Called whenever the preview pane becomes out of sync with the file editor
433
   * tab. This can be called when the text changes, the caret paragraph
434
   * changes,
435
   * or the file tab changes.
436
   *
437
   * @param tab The file editor tab that has been changed in some fashion.
438
   */
439
  private void process( final FileEditorTab tab ) {
440
    if( tab == null ) {
441
      return;
442
    }
443
444
    getPreviewPane().setPath( tab.getPath() );
445
446
    final Processor<String> processor = getProcessors().computeIfAbsent(
447
        tab, p -> createProcessor( tab )
448
    );
449
450
    try {
451
      processor.processChain( tab.getEditorText() );
452
    } catch( final Exception ex ) {
453
      error( ex );
454
    }
455
  }
456
457
  private void renderActiveTab() {
458
    process( getActiveFileEditorTab() );
459
  }
460
461
  /**
462
   * Called when a definition source is opened.
463
   *
464
   * @param path Path to the definition source that was opened.
465
   */
466
  private void openDefinitions( final Path path ) {
467
    try {
468
      final DefinitionSource ds = createDefinitionSource( path );
469
      setDefinitionSource( ds );
470
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
471
      getUserPreferences().save();
472
473
      final Tooltip tooltipPath = new Tooltip( path.toString() );
474
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
475
476
      final DefinitionPane pane = getDefinitionPane();
477
      pane.update( ds );
478
      pane.addTreeChangeHandler( mTreeHandler );
479
      pane.addKeyEventHandler( mDefinitionKeyHandler );
480
      pane.filenameProperty().setValue( path.getFileName().toString() );
481
      pane.setTooltip( tooltipPath );
482
483
      interpolateResolvedMap();
484
    } catch( final Exception e ) {
485
      error( e );
486
    }
487
  }
488
489
  private void exportDefinitions( final Path path ) {
490
    try {
491
      final DefinitionPane pane = getDefinitionPane();
492
      final TreeItem<String> root = pane.getTreeView().getRoot();
493
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
494
495
      if( problemChild == null ) {
496
        getDefinitionSource().getTreeAdapter().export( root, path );
497
        getNotifier().clear();
498
      }
499
      else {
500
        final String msg = get(
501
            "yaml.error.tree.form", problemChild.getValue() );
502
        getNotifier().notify( msg );
503
      }
504
    } catch( final Exception e ) {
505
      error( e );
506
    }
507
  }
508
509
  private void interpolateResolvedMap() {
510
    final Map<String, String> treeMap = getDefinitionPane().toMap();
511
    final Map<String, String> map = new HashMap<>( treeMap );
512
    MapInterpolator.interpolate( map );
513
514
    getResolvedMap().clear();
515
    getResolvedMap().putAll( map );
516
  }
517
518
  private void initDefinitionPane() {
519
    openDefinitions( getDefinitionPath() );
520
  }
521
522
  /**
523
   * Called when an exception occurs that warrants the user's attention.
524
   *
525
   * @param e The exception with a message that the user should know about.
526
   */
527
  private void error( final Exception e ) {
528
    getNotifier().notify( e );
529
  }
530
531
  //---- File actions -------------------------------------------------------
532
533
  /**
534
   * Called when an {@link Observable} instance has changed. This is called
535
   * by both the {@link Snitch} service and the notify service. The @link
536
   * Snitch} service can be called for different file types, including
537
   * {@link DefinitionSource} instances.
538
   *
539
   * @param observable The observed instance.
540
   * @param value      The noteworthy item.
541
   */
542
  @Override
543
  public void update( final Observable observable, final Object value ) {
544
    if( value != null ) {
545
      if( observable instanceof Snitch && value instanceof Path ) {
546
        updateSelectedTab();
547
      }
548
      else if( observable instanceof Notifier && value instanceof String ) {
549
        updateStatusBar( (String) value );
550
      }
551
    }
552
  }
553
554
  /**
555
   * Updates the status bar to show the given message.
556
   *
557
   * @param s The message to show in the status bar.
558
   */
559
  private void updateStatusBar( final String s ) {
560
    Platform.runLater(
561
        () -> {
562
          final int index = s.indexOf( '\n' );
563
          final String message = s.substring(
564
              0, index > 0 ? index : s.length() );
565
566
          getStatusBar().setText( message );
567
        }
568
    );
569
  }
570
571
  /**
572
   * Called when a file has been modified.
573
   */
574
  private void updateSelectedTab() {
575
    Platform.runLater(
576
        () -> {
577
          // Brute-force XSLT file reload by re-instantiating all processors.
578
          resetProcessors();
579
          renderActiveTab();
580
        }
581
    );
582
  }
583
584
  /**
585
   * After resetting the processors, they will refresh anew to be up-to-date
586
   * with the files (text and definition) currently loaded into the editor.
587
   */
588
  private void resetProcessors() {
589
    getProcessors().clear();
590
  }
591
592
  //---- File actions -------------------------------------------------------
593
594
  private void fileNew() {
595
    getFileEditorPane().newEditor();
596
  }
597
598
  private void fileOpen() {
599
    getFileEditorPane().openFileDialog();
600
  }
601
602
  private void fileClose() {
603
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
604
  }
605
606
  /**
607
   * TODO: Upon closing, first remove the tab change listeners. (There's no
608
   * need to re-render each tab when all are being closed.)
609
   */
610
  private void fileCloseAll() {
611
    getFileEditorPane().closeAllEditors();
612
  }
613
614
  private void fileSave() {
615
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
616
  }
617
618
  private void fileSaveAs() {
619
    final FileEditorTab editor = getActiveFileEditorTab();
620
    getFileEditorPane().saveEditorAs( editor );
621
    getProcessors().remove( editor );
622
623
    try {
624
      process( editor );
625
    } catch( final Exception ex ) {
626
      getNotifier().notify( ex );
627
    }
628
  }
629
630
  private void fileSaveAll() {
631
    getFileEditorPane().saveAllEditors();
632
  }
633
634
  private void fileExit() {
635
    final Window window = getWindow();
636
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
637
  }
638
639
  //---- Edit actions -------------------------------------------------------
640
641
  /**
642
   * Used to find text in the active file editor window.
643
   */
644
  private void editFind() {
645
    final TextField input = getFindTextField();
646
    getStatusBar().setGraphic( input );
647
    input.requestFocus();
648
  }
649
650
  public void editFindNext() {
651
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
652
  }
653
654
  public void editPreferences() {
655
    getUserPreferences().show();
656
  }
657
658
  //---- Insert actions -----------------------------------------------------
659
660
  /**
661
   * Delegates to the active editor to handle wrapping the current text
662
   * selection with leading and trailing strings.
663
   *
664
   * @param leading  The string to put before the selection.
665
   * @param trailing The string to put after the selection.
666
   */
667
  private void insertMarkdown(
668
      final String leading, final String trailing ) {
669
    getActiveEditorPane().surroundSelection( leading, trailing );
670
  }
671
672
  @SuppressWarnings("SameParameterValue")
673
  private void insertMarkdown(
674
      final String leading, final String trailing, final String hint ) {
675
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
676
  }
677
678
  //---- Help actions -------------------------------------------------------
679
680
  private void helpAbout() {
681
    final Alert alert = new Alert( AlertType.INFORMATION );
682
    alert.setTitle( get( "Dialog.about.title" ) );
683
    alert.setHeaderText( get( "Dialog.about.header" ) );
684
    alert.setContentText( get( "Dialog.about.content" ) );
685
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
686
    alert.initOwner( getWindow() );
687
688
    alert.showAndWait();
689
  }
690
691
  //---- Member creators ----------------------------------------------------
692
693
  /**
694
   * Factory to create processors that are suited to different file types.
695
   *
696
   * @param tab The tab that is subjected to processing.
697
   * @return A processor suited to the file type specified by the tab's path.
698
   */
699
  private Processor<String> createProcessor( final FileEditorTab tab ) {
700
    return createProcessorFactory().createProcessor( tab );
701
  }
702
703
  private ProcessorFactory createProcessorFactory() {
704
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
705
  }
706
707
  private HTMLPreviewPane createHTMLPreviewPane() {
708
    return new HTMLPreviewPane();
709
  }
710
711
  private DefinitionSource createDefaultDefinitionSource() {
712
    return new YamlDefinitionSource( getDefinitionPath() );
713
  }
714
715
  private DefinitionSource createDefinitionSource( final Path path ) {
716
    try {
717
      return createDefinitionFactory().createDefinitionSource( path );
718
    } catch( final Exception ex ) {
719
      error( ex );
720
      return createDefaultDefinitionSource();
721
    }
722
  }
723
724
  private TextField createFindTextField() {
725
    return new TextField();
726
  }
727
728
  private DefinitionFactory createDefinitionFactory() {
729
    return new DefinitionFactory();
730
  }
731
732
  private StatusBar createStatusBar() {
733
    return new StatusBar();
734
  }
735
736
  private Scene createScene() {
737
    final SplitPane splitPane = new SplitPane(
738
        getDefinitionPane().getNode(),
739
        getFileEditorPane().getNode(),
740
        getPreviewPane().getNode() );
741
742
    splitPane.setDividerPositions(
743
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
744
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
745
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
746
747
    getDefinitionPane().prefHeightProperty()
748
                       .bind( splitPane.heightProperty() );
749
750
    final BorderPane borderPane = new BorderPane();
751
    borderPane.setPrefSize( 1024, 800 );
752
    borderPane.setTop( createMenuBar() );
753
    borderPane.setBottom( getStatusBar() );
754
    borderPane.setCenter( splitPane );
755
756
    final VBox statusBar = new VBox();
757
    statusBar.setAlignment( Pos.BASELINE_CENTER );
758
    statusBar.getChildren().add( getLineNumberText() );
759
    getStatusBar().getRightItems().add( statusBar );
760
761
    return new Scene( borderPane );
762
  }
763
764
  private Text createLineNumberText() {
765
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
766
  }
767
768
  private Node createMenuBar() {
769
    final BooleanBinding activeFileEditorIsNull =
770
        getFileEditorPane().activeFileEditorProperty().isNull();
771
772
    // File actions
773
    final Action fileNewAction = new ActionBuilder()
774
        .setText( "Main.menu.file.new" )
775
        .setAccelerator( "Shortcut+N" )
776
        .setIcon( FILE_ALT )
777
        .setAction( e -> fileNew() )
778
        .build();
779
    final Action fileOpenAction = new ActionBuilder()
780
        .setText( "Main.menu.file.open" )
781
        .setAccelerator( "Shortcut+O" )
782
        .setIcon( FOLDER_OPEN_ALT )
783
        .setAction( e -> fileOpen() )
784
        .build();
785
    final Action fileCloseAction = new ActionBuilder()
786
        .setText( "Main.menu.file.close" )
787
        .setAccelerator( "Shortcut+W" )
788
        .setAction( e -> fileClose() )
789
        .setDisable( activeFileEditorIsNull )
790
        .build();
791
    final Action fileCloseAllAction = new ActionBuilder()
792
        .setText( "Main.menu.file.close_all" )
793
        .setAction( e -> fileCloseAll() )
794
        .setDisable( activeFileEditorIsNull )
795
        .build();
796
    final Action fileSaveAction = new ActionBuilder()
797
        .setText( "Main.menu.file.save" )
798
        .setAccelerator( "Shortcut+S" )
799
        .setIcon( FLOPPY_ALT )
800
        .setAction( e -> fileSave() )
801
        .setDisable( createActiveBooleanProperty(
802
            FileEditorTab::modifiedProperty ).not() )
803
        .build();
804
    final Action fileSaveAsAction = new ActionBuilder()
805
        .setText( "Main.menu.file.save_as" )
806
        .setAction( e -> fileSaveAs() )
807
        .setDisable( activeFileEditorIsNull )
808
        .build();
809
    final Action fileSaveAllAction = new ActionBuilder()
810
        .setText( "Main.menu.file.save_all" )
811
        .setAccelerator( "Shortcut+Shift+S" )
812
        .setAction( e -> fileSaveAll() )
813
        .setDisable( Bindings.not(
814
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
815
        .build();
816
    final Action fileExitAction = new ActionBuilder()
817
        .setText( "Main.menu.file.exit" )
818
        .setAction( e -> fileExit() )
819
        .build();
820
821
    // Edit actions
822
    final Action editUndoAction = new ActionBuilder()
823
        .setText( "Main.menu.edit.undo" )
824
        .setAccelerator( "Shortcut+Z" )
825
        .setIcon( UNDO )
826
        .setAction( e -> getActiveEditorPane().undo() )
827
        .setDisable( createActiveBooleanProperty(
828
            FileEditorTab::canUndoProperty ).not() )
829
        .build();
830
    final Action editRedoAction = new ActionBuilder()
831
        .setText( "Main.menu.edit.redo" )
832
        .setAccelerator( "Shortcut+Y" )
833
        .setIcon( REPEAT )
834
        .setAction( e -> getActiveEditorPane().redo() )
835
        .setDisable( createActiveBooleanProperty(
836
            FileEditorTab::canRedoProperty ).not() )
837
        .build();
838
    final Action editFindAction = new ActionBuilder()
839
        .setText( "Main.menu.edit.find" )
840
        .setAccelerator( "Ctrl+F" )
841
        .setIcon( SEARCH )
842
        .setAction( e -> editFind() )
843
        .setDisable( activeFileEditorIsNull )
844
        .build();
845
    final Action editFindNextAction = new ActionBuilder()
846
        .setText( "Main.menu.edit.find.next" )
847
        .setAccelerator( "F3" )
848
        .setIcon( null )
849
        .setAction( e -> editFindNext() )
850
        .setDisable( activeFileEditorIsNull )
851
        .build();
852
    final Action editPreferencesAction = new ActionBuilder()
853
        .setText( "Main.menu.edit.preferences" )
854
        .setAccelerator( "Ctrl+Alt+S" )
855
        .setAction( e -> editPreferences() )
856
        .build();
857
858
    // Insert actions
859
    final Action insertBoldAction = new ActionBuilder()
860
        .setText( "Main.menu.insert.bold" )
861
        .setAccelerator( "Shortcut+B" )
862
        .setIcon( BOLD )
863
        .setAction( e -> insertMarkdown( "**", "**" ) )
864
        .setDisable( activeFileEditorIsNull )
865
        .build();
866
    final Action insertItalicAction = new ActionBuilder()
867
        .setText( "Main.menu.insert.italic" )
868
        .setAccelerator( "Shortcut+I" )
869
        .setIcon( ITALIC )
870
        .setAction( e -> insertMarkdown( "*", "*" ) )
871
        .setDisable( activeFileEditorIsNull )
872
        .build();
873
    final Action insertSuperscriptAction = new ActionBuilder()
874
        .setText( "Main.menu.insert.superscript" )
875
        .setAccelerator( "Shortcut+[" )
876
        .setIcon( SUPERSCRIPT )
877
        .setAction( e -> insertMarkdown( "^", "^" ) )
878
        .setDisable( activeFileEditorIsNull )
879
        .build();
880
    final Action insertSubscriptAction = new ActionBuilder()
881
        .setText( "Main.menu.insert.subscript" )
882
        .setAccelerator( "Shortcut+]" )
883
        .setIcon( SUBSCRIPT )
884
        .setAction( e -> insertMarkdown( "~", "~" ) )
885
        .setDisable( activeFileEditorIsNull )
886
        .build();
887
    final Action insertStrikethroughAction = new ActionBuilder()
888
        .setText( "Main.menu.insert.strikethrough" )
889
        .setAccelerator( "Shortcut+T" )
890
        .setIcon( STRIKETHROUGH )
891
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
892
        .setDisable( activeFileEditorIsNull )
893
        .build();
894
    final Action insertBlockquoteAction = new ActionBuilder()
895
        .setText( "Main.menu.insert.blockquote" )
896
        .setAccelerator( "Ctrl+Q" )
897
        .setIcon( QUOTE_LEFT )
898
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
899
        .setDisable( activeFileEditorIsNull )
900
        .build();
901
    final Action insertCodeAction = new ActionBuilder()
902
        .setText( "Main.menu.insert.code" )
903
        .setAccelerator( "Shortcut+K" )
904
        .setIcon( CODE )
905
        .setAction( e -> insertMarkdown( "`", "`" ) )
906
        .setDisable( activeFileEditorIsNull )
907
        .build();
908
    final Action insertFencedCodeBlockAction = new ActionBuilder()
909
        .setText( "Main.menu.insert.fenced_code_block" )
910
        .setAccelerator( "Shortcut+Shift+K" )
911
        .setIcon( FILE_CODE_ALT )
912
        .setAction( e -> getActiveEditorPane().surroundSelection(
913
            "\n\n```\n",
914
            "\n```\n\n",
915
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
916
        .setDisable( activeFileEditorIsNull )
917
        .build();
918
    final Action insertLinkAction = new ActionBuilder()
919
        .setText( "Main.menu.insert.link" )
920
        .setAccelerator( "Shortcut+L" )
921
        .setIcon( LINK )
922
        .setAction( e -> getActiveEditorPane().insertLink() )
923
        .setDisable( activeFileEditorIsNull )
924
        .build();
925
    final Action insertImageAction = new ActionBuilder()
926
        .setText( "Main.menu.insert.image" )
927
        .setAccelerator( "Shortcut+G" )
928
        .setIcon( PICTURE_ALT )
929
        .setAction( e -> getActiveEditorPane().insertImage() )
930
        .setDisable( activeFileEditorIsNull )
931
        .build();
932
933
    // Number of header actions (H1 ... H3)
934
    final int HEADERS = 3;
935
    final Action[] headers = new Action[ HEADERS ];
936
937
    for( int i = 1; i <= HEADERS; i++ ) {
938
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
939
      final String markup = String.format( "%n%n%s ", hashes );
940
      final String text = "Main.menu.insert.header." + i;
941
      final String accelerator = "Shortcut+" + i;
942
      final String prompt = text + ".prompt";
943
944
      headers[ i - 1 ] = new ActionBuilder()
945
          .setText( text )
946
          .setAccelerator( accelerator )
947
          .setIcon( HEADER )
948
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
949
          .setDisable( activeFileEditorIsNull )
950
          .build();
951
    }
952
953
    final Action insertUnorderedListAction = new ActionBuilder()
954
        .setText( "Main.menu.insert.unordered_list" )
955
        .setAccelerator( "Shortcut+U" )
956
        .setIcon( LIST_UL )
957
        .setAction( e -> getActiveEditorPane()
958
            .surroundSelection( "\n\n* ", "" ) )
48
import com.scrivenvar.util.ResourceWalker;
49
import javafx.application.Platform;
50
import javafx.beans.binding.Bindings;
51
import javafx.beans.binding.BooleanBinding;
52
import javafx.beans.property.BooleanProperty;
53
import javafx.beans.property.SimpleBooleanProperty;
54
import javafx.beans.value.ChangeListener;
55
import javafx.beans.value.ObservableBooleanValue;
56
import javafx.beans.value.ObservableValue;
57
import javafx.collections.ListChangeListener.Change;
58
import javafx.collections.ObservableList;
59
import javafx.event.Event;
60
import javafx.event.EventHandler;
61
import javafx.geometry.Pos;
62
import javafx.scene.Node;
63
import javafx.scene.Scene;
64
import javafx.scene.control.*;
65
import javafx.scene.control.Menu;
66
import javafx.scene.control.MenuBar;
67
import javafx.scene.control.TextField;
68
import javafx.scene.control.Alert.AlertType;
69
import javafx.scene.image.Image;
70
import javafx.scene.image.ImageView;
71
import javafx.scene.input.KeyEvent;
72
import javafx.scene.layout.BorderPane;
73
import javafx.scene.layout.VBox;
74
import javafx.scene.text.Text;
75
import javafx.stage.Window;
76
import javafx.stage.WindowEvent;
77
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 );
775
776
    return new Scene( borderPane );
777
  }
778
779
  private Text createLineNumberText() {
780
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
781
  }
782
783
  private Node createMenuBar() {
784
    final BooleanBinding activeFileEditorIsNull =
785
        getFileEditorPane().activeFileEditorProperty().isNull();
786
787
    // File actions
788
    final Action fileNewAction = new ActionBuilder()
789
        .setText( "Main.menu.file.new" )
790
        .setAccelerator( "Shortcut+N" )
791
        .setIcon( FILE_ALT )
792
        .setAction( e -> fileNew() )
793
        .build();
794
    final Action fileOpenAction = new ActionBuilder()
795
        .setText( "Main.menu.file.open" )
796
        .setAccelerator( "Shortcut+O" )
797
        .setIcon( FOLDER_OPEN_ALT )
798
        .setAction( e -> fileOpen() )
799
        .build();
800
    final Action fileCloseAction = new ActionBuilder()
801
        .setText( "Main.menu.file.close" )
802
        .setAccelerator( "Shortcut+W" )
803
        .setAction( e -> fileClose() )
804
        .setDisable( activeFileEditorIsNull )
805
        .build();
806
    final Action fileCloseAllAction = new ActionBuilder()
807
        .setText( "Main.menu.file.close_all" )
808
        .setAction( e -> fileCloseAll() )
809
        .setDisable( activeFileEditorIsNull )
810
        .build();
811
    final Action fileSaveAction = new ActionBuilder()
812
        .setText( "Main.menu.file.save" )
813
        .setAccelerator( "Shortcut+S" )
814
        .setIcon( FLOPPY_ALT )
815
        .setAction( e -> fileSave() )
816
        .setDisable( createActiveBooleanProperty(
817
            FileEditorTab::modifiedProperty ).not() )
818
        .build();
819
    final Action fileSaveAsAction = new ActionBuilder()
820
        .setText( "Main.menu.file.save_as" )
821
        .setAction( e -> fileSaveAs() )
822
        .setDisable( activeFileEditorIsNull )
823
        .build();
824
    final Action fileSaveAllAction = new ActionBuilder()
825
        .setText( "Main.menu.file.save_all" )
826
        .setAccelerator( "Shortcut+Shift+S" )
827
        .setAction( e -> fileSaveAll() )
828
        .setDisable( Bindings.not(
829
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
830
        .build();
831
    final Action fileExitAction = new ActionBuilder()
832
        .setText( "Main.menu.file.exit" )
833
        .setAction( e -> fileExit() )
834
        .build();
835
836
    // Edit actions
837
    final Action editUndoAction = new ActionBuilder()
838
        .setText( "Main.menu.edit.undo" )
839
        .setAccelerator( "Shortcut+Z" )
840
        .setIcon( UNDO )
841
        .setAction( e -> getActiveEditorPane().undo() )
842
        .setDisable( createActiveBooleanProperty(
843
            FileEditorTab::canUndoProperty ).not() )
844
        .build();
845
    final Action editRedoAction = new ActionBuilder()
846
        .setText( "Main.menu.edit.redo" )
847
        .setAccelerator( "Shortcut+Y" )
848
        .setIcon( REPEAT )
849
        .setAction( e -> getActiveEditorPane().redo() )
850
        .setDisable( createActiveBooleanProperty(
851
            FileEditorTab::canRedoProperty ).not() )
852
        .build();
853
    final Action editFindAction = new ActionBuilder()
854
        .setText( "Main.menu.edit.find" )
855
        .setAccelerator( "Ctrl+F" )
856
        .setIcon( SEARCH )
857
        .setAction( e -> editFind() )
858
        .setDisable( activeFileEditorIsNull )
859
        .build();
860
    final Action editFindNextAction = new ActionBuilder()
861
        .setText( "Main.menu.edit.find.next" )
862
        .setAccelerator( "F3" )
863
        .setIcon( null )
864
        .setAction( e -> editFindNext() )
865
        .setDisable( activeFileEditorIsNull )
866
        .build();
867
    final Action editPreferencesAction = new ActionBuilder()
868
        .setText( "Main.menu.edit.preferences" )
869
        .setAccelerator( "Ctrl+Alt+S" )
870
        .setAction( e -> editPreferences() )
871
        .build();
872
873
    // Insert actions
874
    final Action insertBoldAction = new ActionBuilder()
875
        .setText( "Main.menu.insert.bold" )
876
        .setAccelerator( "Shortcut+B" )
877
        .setIcon( BOLD )
878
        .setAction( e -> insertMarkdown( "**", "**" ) )
879
        .setDisable( activeFileEditorIsNull )
880
        .build();
881
    final Action insertItalicAction = new ActionBuilder()
882
        .setText( "Main.menu.insert.italic" )
883
        .setAccelerator( "Shortcut+I" )
884
        .setIcon( ITALIC )
885
        .setAction( e -> insertMarkdown( "*", "*" ) )
886
        .setDisable( activeFileEditorIsNull )
887
        .build();
888
    final Action insertSuperscriptAction = new ActionBuilder()
889
        .setText( "Main.menu.insert.superscript" )
890
        .setAccelerator( "Shortcut+[" )
891
        .setIcon( SUPERSCRIPT )
892
        .setAction( e -> insertMarkdown( "^", "^" ) )
893
        .setDisable( activeFileEditorIsNull )
894
        .build();
895
    final Action insertSubscriptAction = new ActionBuilder()
896
        .setText( "Main.menu.insert.subscript" )
897
        .setAccelerator( "Shortcut+]" )
898
        .setIcon( SUBSCRIPT )
899
        .setAction( e -> insertMarkdown( "~", "~" ) )
900
        .setDisable( activeFileEditorIsNull )
901
        .build();
902
    final Action insertStrikethroughAction = new ActionBuilder()
903
        .setText( "Main.menu.insert.strikethrough" )
904
        .setAccelerator( "Shortcut+T" )
905
        .setIcon( STRIKETHROUGH )
906
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
907
        .setDisable( activeFileEditorIsNull )
908
        .build();
909
    final Action insertBlockquoteAction = new ActionBuilder()
910
        .setText( "Main.menu.insert.blockquote" )
911
        .setAccelerator( "Ctrl+Q" )
912
        .setIcon( QUOTE_LEFT )
913
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
914
        .setDisable( activeFileEditorIsNull )
915
        .build();
916
    final Action insertCodeAction = new ActionBuilder()
917
        .setText( "Main.menu.insert.code" )
918
        .setAccelerator( "Shortcut+K" )
919
        .setIcon( CODE )
920
        .setAction( e -> insertMarkdown( "`", "`" ) )
921
        .setDisable( activeFileEditorIsNull )
922
        .build();
923
    final Action insertFencedCodeBlockAction = new ActionBuilder()
924
        .setText( "Main.menu.insert.fenced_code_block" )
925
        .setAccelerator( "Shortcut+Shift+K" )
926
        .setIcon( FILE_CODE_ALT )
927
        .setAction( e -> insertMarkdown(
928
            "\n\n```\n",
929
            "\n```\n\n",
930
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
931
        .setDisable( activeFileEditorIsNull )
932
        .build();
933
    final Action insertLinkAction = new ActionBuilder()
934
        .setText( "Main.menu.insert.link" )
935
        .setAccelerator( "Shortcut+L" )
936
        .setIcon( LINK )
937
        .setAction( e -> getActiveEditorPane().insertLink() )
938
        .setDisable( activeFileEditorIsNull )
939
        .build();
940
    final Action insertImageAction = new ActionBuilder()
941
        .setText( "Main.menu.insert.image" )
942
        .setAccelerator( "Shortcut+G" )
943
        .setIcon( PICTURE_ALT )
944
        .setAction( e -> getActiveEditorPane().insertImage() )
945
        .setDisable( activeFileEditorIsNull )
946
        .build();
947
948
    // Number of header actions (H1 ... H3)
949
    final int HEADERS = 3;
950
    final Action[] headers = new Action[ HEADERS ];
951
952
    for( int i = 1; i <= HEADERS; i++ ) {
953
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
954
      final String markup = String.format( "%n%n%s ", hashes );
955
      final String text = "Main.menu.insert.header." + i;
956
      final String accelerator = "Shortcut+" + i;
957
      final String prompt = text + ".prompt";
958
959
      headers[ i - 1 ] = new ActionBuilder()
960
          .setText( text )
961
          .setAccelerator( accelerator )
962
          .setIcon( HEADER )
963
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
964
          .setDisable( activeFileEditorIsNull )
965
          .build();
966
    }
967
968
    final Action insertUnorderedListAction = new ActionBuilder()
969
        .setText( "Main.menu.insert.unordered_list" )
970
        .setAccelerator( "Shortcut+U" )
971
        .setIcon( LIST_UL )
972
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
959973
        .setDisable( activeFileEditorIsNull )
960974
        .build();
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
193193
194194
      if( box == null ) {
195
        srollToBottom();
195
        scrollToBottom();
196196
      }
197197
      else {
...
240240
  }
241241
242
  private void srollToBottom() {
242
  private void scrollToBottom() {
243243
    scrollToY( mRenderer.getHeight() );
244244
  }
...
284284
  }
285285
286
  /**
287
   * Creates a {@link Point} to use as a reference for scrolling to the area
288
   * described by the given {@link Box}. The {@link Box} coordinates are used
289
   * to populate the {@link Point}'s location, with minor adjustments for
290
   * vertical centering.
291
   *
292
   * @param box The {@link Box} that represents a scrolling anchor reference.
293
   * @return A coordinate suitable for scrolling to.
294
   */
286295
  private Point createPoint( final Box box ) {
287296
    assert box != null;
A src/main/java/com/scrivenvar/util/ResourceWalker.java
1
package com.scrivenvar.util;
2
3
import java.io.IOException;
4
import java.net.URISyntaxException;
5
import java.nio.file.*;
6
import java.util.function.Consumer;
7
8
import static java.nio.file.FileSystems.newFileSystem;
9
import static java.util.Collections.emptyMap;
10
11
/**
12
 * Responsible for finding file resources.
13
 */
14
public class ResourceWalker {
15
  private static final PathMatcher PATH_MATCHER =
16
      FileSystems.getDefault().getPathMatcher( "glob:**.ttf" );
17
18
  /**
19
   * @param dirName The root directory to scan for files matching the glob.
20
   * @param c       The consumer function to call for each matching path found.
21
   * @throws URISyntaxException Could not convert the resource to a URI.
22
   * @throws IOException        Could not walk the tree.
23
   */
24
  public static void walk( final String dirName, final Consumer<Path> c )
25
      throws URISyntaxException, IOException {
26
    final var resource = ResourceWalker.class.getResource( dirName );
27
28
    if( resource != null ) {
29
      final var uri = resource.toURI();
30
      final var path = uri.getScheme().equals( "jar" )
31
          ? newFileSystem( uri, emptyMap() ).getPath( dirName )
32
          : Paths.get( uri );
33
      final var walk = Files.walk( path, 10 );
34
35
      for( final var it = walk.iterator(); it.hasNext(); ) {
36
        final Path p = it.next();
37
        if( PATH_MATCHER.matches( p ) ) {
38
          c.accept( p );
39
        }
40
      }
41
    }
42
  }
43
}
144
M src/main/resources/com/scrivenvar/preview/webview.css
2929
blockquote:before, blockquote:after,
3030
q:before, q:after {
31
	content: '';
31
	content: "";
3232
	content: none;
3333
}
...
4040
=============================================================================*/
4141
body {
42
  font-family: Vollkorn, serif;
42
  font-family: "Vollkorn", serif;
4343
  font-size: 16px;
4444
  background-color: #fff;
...
176176
=============================================================================*/
177177
pre, code, tt {
178
  font-size: 12px;
179
  font-family: Consolas, "Liberation Mono", Courier, monospace;
178
  font-size: 14px;
179
  font-family: "Fira Code", monospace;
180180
}
181181
...
196196
  background-color: #f8f8f8;
197197
  border: .125em solid #ccc;
198
  font-size: 13px;
199
  line-height: 19px;
198
  line-height: 1.6;
200199
  overflow: auto;
201200
  padding: .25em .5em;