Dave Jarvis' Repositories

M BUILD.md
77
Download and install the following software packages:
88
9
* [OpenJDK 14](https://openjdk.java.net)
9
* [OpenJDK 14.0.2](https://openjdk.java.net) (full JDK, including JavaFX)
1010
* [Gradle 6.4](https://gradle.org/releases)
11
* [Git 2.28.0](https://git-scm.com/downloads)
12
13
## Repository
14
15
Clone the repository as follows:
16
17
    git clone https://github.com/DaveJarvis/keenwrite.git
18
19
The repository is cloned.
1120
1221
# Build
1322
1423
Build the application überjar as follows:
1524
25
    cd keenwrite
1626
    gradle clean jar
1727
...
2737
2838
    java -jar build\libs\keenwrite.jar
39
40
# Integrated development environments
41
42
This section describes setup instructions to import and run the application using an integrated development environment (IDE). Running the application should trigger a build.
43
44
## IntelliJ IDEA
45
46
This section describes how to build and run the application using IntellIJ's IDEA.
47
48
### Import
49
50
Complete the following steps to import the application:
51
52
1. Start the IDE.
53
1. Click **File → New → Project from Existing Sources**.
54
1. Browse to the directory containing `keenwrite`.
55
1. Click **OK**.
56
1. Select **Gradle** as the external model.
57
1. Click **Finish**.
58
59
The project is imported into the IDE.
60
61
### Run
62
63
Run the application as follows:
64
65
1. Ensure the **Project** is open.
66
1. Expand **src → main → java → com.keenwrite**.
67
1. Open **Launcher**.
68
1. Run **main**.
69
70
The application is launched.
2971
3072
# Installers
3173
32
This section describes how to set up the development environment and
33
build native executables for supported operating systems.
74
This section describes how to set up the development environment and build native executables for supported operating systems.
3475
3576
## Setup
3677
3778
Follow these one-time setup instructions to begin:
3879
3980
1. Ensure `$HOME/bin` is set in the `PATH` environment variable.
40
1. Move `build-template` into `$HOME/bin`.
81
1. Copy `build-template` into `$HOME/bin`.
4182
4283
Setup is complete.
...
59100
# Versioning
60101
61
Version numbers are read directly from Git using a plugin. The version
62
number is written to `app.properties`, a properties file in the `resources`
63
directory that can be read from within the application.
102
Version numbers are read directly from Git using a plugin. The version number is written to `app.properties` in the `resources` directory. The application reads that file to display version information upon start.
64103
65104
A docs/quadratic.Rmd
1
![Logo](images/app-title)
2
3
Given the quadratic formula:
4
5
$x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$
6
7
Formatted in an R Markdown document as follows:
8
9
    $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$
10
11
We can substitute the following values:
12
13
$a = `r# x(v$formula$quadratic$a)`, b = `r# x(v$formula$quadratic$b)`, c = `r# x(v$formula$quadratic$c)`$
14
15
`r# -x(v$formula$quadratic$b) + sqrt( v$formula$quadratic$b^2  - 4 * v$formula$quadratic$a * v$formula$quadratic$c )`
16
17
To arrive at two solutions:
18
19
$x = \frac{-b + \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) + sqrt( x(v$formula$quadratic$b)^2  - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$
20
21
$x = \frac{-b - \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) - sqrt( x(v$formula$quadratic$b)^2  - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$
22
23
Changing the variable values is reflected in the output immediately.
124
M docs/variables.yaml
22
formula:
33
  sqrt:
4
    value: "42"
4
    value: "420"
5
  quadratic:
6
    a: "25"
7
    b: "84.906"
8
    c: "20"
59
M src/main/java/com/keenwrite/AbstractFileFactory.java
2828
package com.keenwrite;
2929
30
import com.keenwrite.service.Settings;
31
import com.keenwrite.util.ProtocolScheme;
32
3330
import java.nio.file.Path;
3431
3532
import static com.keenwrite.Constants.GLOB_PREFIX_FILE;
3633
import static com.keenwrite.Constants.SETTINGS;
3734
import static com.keenwrite.FileType.UNKNOWN;
3835
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
39
import static java.lang.String.format;
4036
4137
/**
4238
 * Provides common behaviours for factories that instantiate classes based on
4339
 * file type.
4440
 */
45
public class AbstractFileFactory {
46
47
  private static final String MSG_UNKNOWN_FILE_TYPE =
48
      "Unknown type '%s' for file '%s'.";
41
public abstract class AbstractFileFactory {
4942
5043
  /**
...
5750
   * @return The FileType for the given path.
5851
   */
59
  public FileType lookup( final Path path ) {
52
  public static FileType lookup( final Path path ) {
6053
    return lookup( path, GLOB_PREFIX_FILE );
6154
  }
...
6861
   * @return The file type that corresponds to the given path.
6962
   */
70
  protected FileType lookup( final Path path, final String prefix ) {
63
  protected static FileType lookup( final Path path, final String prefix ) {
7164
    assert path != null;
7265
    assert prefix != null;
7366
74
    final var settings = getSettings();
75
    final var keys = settings.getKeys( prefix );
67
    final var keys = SETTINGS.getKeys( prefix );
7668
7769
    var found = false;
7870
    var fileType = UNKNOWN;
7971
8072
    while( keys.hasNext() && !found ) {
8173
      final var key = keys.next();
82
      final var patterns = settings.getStringSettingList( key );
74
      final var patterns = SETTINGS.getStringSettingList( key );
8375
      final var predicate = createFileTypePredicate( patterns );
8476
...
9284
9385
    return fileType;
94
  }
95
96
  /**
97
   * Throws IllegalArgumentException because the given path could not be
98
   * recognized. This exists because
99
   *
100
   * @param type The detected path type (protocol, file extension, etc.).
101
   * @param path The path to a source of definitions.
102
   */
103
  protected void unknownFileType(
104
      final ProtocolScheme type, final String path ) {
105
    final String msg = format( MSG_UNKNOWN_FILE_TYPE, type, path );
106
    throw new IllegalArgumentException( msg );
107
  }
108
109
  /**
110
   * Return the singleton Settings instance.
111
   *
112
   * @return A non-null instance.
113
   */
114
  private Settings getSettings() {
115
    return SETTINGS;
11686
  }
11787
}
A src/main/java/com/keenwrite/ExportFormat.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite;
29
30
import java.io.File;
31
32
import static org.apache.commons.io.FilenameUtils.removeExtension;
33
34
/**
35
 * Provides controls for processor behaviour when transforming input documents.
36
 */
37
public enum ExportFormat {
38
39
  /**
40
   * For HTML exports, encode TeX as SVG.
41
   */
42
  HTML_TEX_SVG( ".html" ),
43
44
  /**
45
   * For HTML exports, encode TeX using {@code $} delimiters, suitable for
46
   * rendering by an external TeX typesetting engine (or online with KaTeX).
47
   */
48
  HTML_TEX_DELIMITED( ".html" ),
49
50
  /**
51
   * Indicates that the processors should export to a Markdown format.
52
   */
53
  MARKDOWN_PLAIN( ".out.md" ),
54
55
  /**
56
   * Indicates no special export format is to be created. No extension is
57
   * applicable.
58
   */
59
  NONE( "" );
60
61
  /**
62
   * Preferred file name extension for the given file type.
63
   */
64
  private final String mExtension;
65
66
  private ExportFormat( final String extension ) {
67
    mExtension = extension;
68
  }
69
70
  public boolean isHtml() {
71
    return this == HTML_TEX_SVG || this == HTML_TEX_DELIMITED;
72
  }
73
74
  public boolean isMarkdown() {
75
    return this == MARKDOWN_PLAIN;
76
  }
77
78
  /**
79
   * Returns the given file renamed with the extension that matches this
80
   * {@link ExportFormat} extension.
81
   *
82
   * @param file The file to rename.
83
   * @return The renamed version of the given file.
84
   */
85
  public File toExportFilename( final File file ) {
86
    return new File( removeExtension( file.getName() ) + mExtension );
87
  }
88
}
189
M src/main/java/com/keenwrite/FileEditorTab.java
5656
5757
import static com.keenwrite.Messages.get;
58
import static com.keenwrite.StatusBarNotifier.alert;
58
import static com.keenwrite.StatusBarNotifier.clue;
5959
import static com.keenwrite.StatusBarNotifier.getNotifier;
6060
import static java.nio.charset.StandardCharsets.UTF_8;
...
253253
        else {
254254
          final String msg = get( "FileEditor.loadFailed.reason.permissions" );
255
          alert( "FileEditor.loadFailed.message", file.toString(), msg );
255
          clue( "FileEditor.loadFailed.message", file.toString(), msg );
256256
        }
257257
      }
258258
    } catch( final Exception ex ) {
259
      alert( ex );
259
      clue( ex );
260260
    }
261261
  }
...
305305
      service.createError( getWindow(), message ).showAndWait();
306306
    } catch( final Exception ex ) {
307
      alert( ex );
307
      clue( ex );
308308
    }
309309
M src/main/java/com/keenwrite/FileEditorTabPane.java
239239
240240
  void openFileDialog() {
241
    final String title = get( "Dialog.file.choose.open.title" );
242
    final FileChooser dialog = createFileChooser( title );
243
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
244
245
    if( files != null ) {
246
      openFiles( files );
247
    }
248
  }
249
250
  /**
251
   * Opens the files into new editors, unless one of those files was a
252
   * definition file. The definition file is loaded into the definition pane,
253
   * but only the first one selected (multiple definition files will result in a
254
   * warning).
255
   *
256
   * @param files The list of non-definition files that the were requested to
257
   *              open.
258
   */
259
  private void openFiles( final List<File> files ) {
260
    final List<String> extensions =
261
        createExtensionFilter( DEFINITION ).getExtensions();
262
    final var predicate = createFileTypePredicate( extensions );
263
264
    // The user might have opened multiple definitions files. These will
265
    // be discarded from the text editable files.
266
    final var definitions
267
        = files.stream().filter( predicate ).collect( Collectors.toList() );
268
269
    // Create a modifiable list to remove any definition files that were
270
    // opened.
271
    final var editors = new ArrayList<>( files );
272
273
    if( !editors.isEmpty() ) {
274
      saveLastDirectory( editors.get( 0 ) );
275
    }
276
277
    editors.removeAll( definitions );
278
279
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
280
    if( !editors.isEmpty() ) {
281
      openEditors( editors, 0 );
282
    }
283
284
    if( !definitions.isEmpty() ) {
285
      openDefinition( definitions.get( 0 ) );
286
    }
287
  }
288
289
  private void openEditors( final List<File> files, final int activeIndex ) {
290
    final int fileTally = files.size();
291
    final List<Tab> tabs = getTabs();
292
293
    // Close single unmodified "Untitled" tab.
294
    if( tabs.size() == 1 ) {
295
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
296
297
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
298
        closeEditor( fileEditor, false );
299
      }
300
    }
301
302
    for( int i = 0; i < fileTally; i++ ) {
303
      final Path path = files.get( i ).toPath();
304
305
      FileEditorTab fileEditorTab = findEditor( path );
306
307
      // Only open new files.
308
      if( fileEditorTab == null ) {
309
        fileEditorTab = createFileEditor( path );
310
        getTabs().add( fileEditorTab );
311
      }
312
313
      // Select the first file in the list.
314
      if( i == activeIndex ) {
315
        getSelectionModel().select( fileEditorTab );
316
      }
317
    }
318
  }
319
320
  /**
321
   * Returns a property that changes when a new definition file is opened.
322
   *
323
   * @return The path to a definition file that was opened.
324
   */
325
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
326
    return getOnOpenDefinitionFile().getReadOnlyProperty();
327
  }
328
329
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
330
    return mOpenDefinition;
331
  }
332
333
  /**
334
   * Called when the user has opened a definition file (using the file open
335
   * dialog box). This will replace the current set of definitions for the
336
   * active tab.
337
   *
338
   * @param definition The file to open.
339
   */
340
  private void openDefinition( final File definition ) {
341
    // TODO: Prevent reading this file twice when a new text document is opened.
342
    // (might be a matter of checking the value first).
343
    getOnOpenDefinitionFile().set( definition.toPath() );
344
  }
345
346
  /**
347
   * Called when the contents of the editor are to be saved.
348
   *
349
   * @param tab The tab containing content to save.
350
   * @return true The contents were saved (or needn't be saved).
351
   */
352
  public boolean saveEditor( final FileEditorTab tab ) {
353
    if( tab == null || !tab.isModified() ) {
354
      return true;
355
    }
356
357
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
358
  }
359
360
  /**
361
   * Opens the Save As dialog for the user to save the content under a new
362
   * path.
363
   *
364
   * @param tab The tab with contents to save.
365
   * @return true The contents were saved, or the tab was null.
366
   */
367
  public boolean saveEditorAs( final FileEditorTab tab ) {
368
    if( tab == null ) {
369
      return true;
370
    }
371
372
    getSelectionModel().select( tab );
373
374
    final FileChooser fileChooser = createFileChooser( get(
375
        "Dialog.file.choose.save.title" ) );
376
    final File file = fileChooser.showSaveDialog( getWindow() );
377
    if( file == null ) {
378
      return false;
379
    }
380
381
    saveLastDirectory( file );
382
    tab.setPath( file.toPath() );
383
384
    return tab.save();
385
  }
386
387
  void saveAllEditors() {
388
    for( final FileEditorTab fileEditor : getAllEditors() ) {
389
      saveEditor( fileEditor );
390
    }
391
  }
392
393
  /**
394
   * Answers whether the file has had modifications. '
395
   *
396
   * @param tab THe tab to check for modifications.
397
   * @return false The file is unmodified.
398
   */
399
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
400
  boolean canCloseEditor( final FileEditorTab tab ) {
401
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
402
    canClose.set( true );
403
404
    if( tab.isModified() ) {
405
      final Notification message = getNotifyService().createNotification(
406
          Messages.get( "Alert.file.close.title" ),
407
          Messages.get( "Alert.file.close.text" ),
408
          tab.getText()
409
      );
410
411
      final Alert confirmSave = getNotifyService().createConfirmation(
412
          getWindow(), message );
413
414
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
415
416
      buttonType.ifPresent(
417
          save -> canClose.set(
418
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
419
          )
420
      );
421
    }
422
423
    return canClose.get();
424
  }
425
426
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
427
    if( tab == null ) {
428
      return true;
429
    }
430
431
    if( save ) {
432
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
433
      Event.fireEvent( tab, event );
434
435
      if( event.isConsumed() ) {
436
        return false;
437
      }
438
    }
439
440
    getTabs().remove( tab );
441
442
    if( tab.getOnClosed() != null ) {
443
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
444
    }
445
446
    return true;
447
  }
448
449
  boolean closeAllEditors() {
450
    final FileEditorTab[] allEditors = getAllEditors();
451
    final FileEditorTab activeEditor = getActiveFileEditor();
452
453
    // try to save active tab first because in case the user decides to cancel,
454
    // then it stays active
455
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
456
      return false;
457
    }
458
459
    // This should be called any time a tab changes.
460
    persistPreferences();
461
462
    // save modified tabs
463
    for( int i = 0; i < allEditors.length; i++ ) {
464
      final FileEditorTab fileEditor = allEditors[ i ];
465
466
      if( fileEditor == activeEditor ) {
467
        continue;
468
      }
469
470
      if( fileEditor.isModified() ) {
471
        // activate the modified tab to make its modified content visible to
472
        // the user
473
        getSelectionModel().select( i );
474
475
        if( !canCloseEditor( fileEditor ) ) {
476
          return false;
477
        }
478
      }
479
    }
480
481
    // Close all tabs.
482
    for( final FileEditorTab fileEditor : allEditors ) {
483
      if( !closeEditor( fileEditor, false ) ) {
484
        return false;
485
      }
486
    }
487
488
    return getTabs().isEmpty();
489
  }
490
491
  private FileEditorTab[] getAllEditors() {
492
    final ObservableList<Tab> tabs = getTabs();
493
    final int length = tabs.size();
494
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
495
496
    for( int i = 0; i < length; i++ ) {
497
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
498
    }
499
500
    return allEditors;
501
  }
502
503
  /**
504
   * Returns the file editor tab that has the given path.
505
   *
506
   * @return null No file editor tab for the given path was found.
507
   */
508
  private FileEditorTab findEditor( final Path path ) {
509
    for( final Tab tab : getTabs() ) {
510
      final FileEditorTab fileEditor = (FileEditorTab) tab;
511
512
      if( fileEditor.isPath( path ) ) {
513
        return fileEditor;
514
      }
515
    }
516
517
    return null;
518
  }
519
520
  private FileChooser createFileChooser( String title ) {
521
    final FileChooser fileChooser = new FileChooser();
522
523
    fileChooser.setTitle( title );
524
    fileChooser.getExtensionFilters().addAll(
525
        createExtensionFilters() );
526
527
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
528
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
529
530
    if( !file.isDirectory() ) {
531
      file = new File( "." );
532
    }
533
534
    fileChooser.setInitialDirectory( file );
535
    return fileChooser;
536
  }
537
538
  private List<ExtensionFilter> createExtensionFilters() {
539
    final List<ExtensionFilter> list = new ArrayList<>();
540
541
    // TODO: Return a list of all properties that match the filter prefix.
542
    // This will allow dynamic filters to be added and removed just by
543
    // updating the properties file.
544
    list.add( createExtensionFilter( ALL ) );
545
    list.add( createExtensionFilter( SOURCE ) );
546
    list.add( createExtensionFilter( DEFINITION ) );
547
    list.add( createExtensionFilter( XML ) );
548
    return list;
549
  }
550
551
  /**
552
   * Returns a filter for file name extensions recognized by the application
553
   * that can be opened by the user.
554
   *
555
   * @param filetype Used to find the globbing pattern for extensions.
556
   * @return A filename filter suitable for use by a FileDialog instance.
557
   */
558
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
559
    final String tKey = String.format( "%s.title.%s",
560
                                       FILTER_EXTENSION_TITLES,
561
                                       filetype );
562
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
563
564
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
565
  }
566
567
  private void saveLastDirectory( final File file ) {
568
    getPreferences().put( "lastDirectory", file.getParent() );
569
  }
570
571
  public void initPreferences() {
572
    int activeIndex = 0;
573
574
    final Preferences preferences = getPreferences();
575
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
576
    final String activeFileName = preferences.get( "activeFile", null );
577
578
    final List<File> files = new ArrayList<>( fileNames.length );
579
580
    for( final String fileName : fileNames ) {
581
      final File file = new File( fileName );
582
583
      if( file.exists() ) {
584
        files.add( file );
585
586
        if( fileName.equals( activeFileName ) ) {
587
          activeIndex = files.size() - 1;
588
        }
589
      }
590
    }
591
592
    if( files.isEmpty() ) {
593
      newEditor();
594
    }
595
    else {
596
      openEditors( files, activeIndex );
597
    }
598
  }
599
600
  public void persistPreferences() {
601
    final var allEditors = getTabs();
602
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
603
604
    for( final var tab : allEditors ) {
605
      final var fileEditor = (FileEditorTab) tab;
606
      final var filePath = fileEditor.getPath();
607
608
      if( filePath != null ) {
609
        fileNames.add( filePath.toString() );
610
      }
611
    }
612
613
    final var preferences = getPreferences();
614
    Utils.putPrefsStrings( preferences,
615
                           "file",
616
                           fileNames.toArray( new String[ 0 ] ) );
617
618
    final var activeEditor = getActiveFileEditor();
619
    final var filePath = activeEditor == null ? null : activeEditor.getPath();
620
621
    if( filePath == null ) {
622
      preferences.remove( "activeFile" );
623
    }
624
    else {
625
      preferences.put( "activeFile", filePath.toString() );
626
    }
627
  }
628
629
  private List<String> getExtensions( final String key ) {
630
    return getSettings().getStringSettingList( key );
631
  }
632
633
  private Notifier getNotifyService() {
634
    return sNotifier;
635
  }
636
637
  private Settings getSettings() {
638
    return SETTINGS;
639
  }
640
641
  protected Options getOptions() {
642
    return sOptions;
643
  }
644
645
  private Window getWindow() {
646
    return getScene().getWindow();
647
  }
648
649
  private Preferences getPreferences() {
650
    return getOptions().getState();
241
    final FileChooser dialog = createFileChooser(
242
        "Dialog.file.choose.open.title" );
243
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
244
245
    if( files != null ) {
246
      openFiles( files );
247
    }
248
  }
249
250
  /**
251
   * Opens the files into new editors, unless one of those files was a
252
   * definition file. The definition file is loaded into the definition pane,
253
   * but only the first one selected (multiple definition files will result in a
254
   * warning).
255
   *
256
   * @param files The list of non-definition files that the were requested to
257
   *              open.
258
   */
259
  private void openFiles( final List<File> files ) {
260
    final List<String> extensions =
261
        createExtensionFilter( DEFINITION ).getExtensions();
262
    final var predicate = createFileTypePredicate( extensions );
263
264
    // The user might have opened multiple definitions files. These will
265
    // be discarded from the text editable files.
266
    final var definitions
267
        = files.stream().filter( predicate ).collect( Collectors.toList() );
268
269
    // Create a modifiable list to remove any definition files that were
270
    // opened.
271
    final var editors = new ArrayList<>( files );
272
273
    if( !editors.isEmpty() ) {
274
      saveLastDirectory( editors.get( 0 ) );
275
    }
276
277
    editors.removeAll( definitions );
278
279
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
280
    if( !editors.isEmpty() ) {
281
      openEditors( editors, 0 );
282
    }
283
284
    if( !definitions.isEmpty() ) {
285
      openDefinition( definitions.get( 0 ) );
286
    }
287
  }
288
289
  private void openEditors( final List<File> files, final int activeIndex ) {
290
    final int fileTally = files.size();
291
    final List<Tab> tabs = getTabs();
292
293
    // Close single unmodified "Untitled" tab.
294
    if( tabs.size() == 1 ) {
295
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
296
297
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
298
        closeEditor( fileEditor, false );
299
      }
300
    }
301
302
    for( int i = 0; i < fileTally; i++ ) {
303
      final Path path = files.get( i ).toPath();
304
305
      FileEditorTab fileEditorTab = findEditor( path );
306
307
      // Only open new files.
308
      if( fileEditorTab == null ) {
309
        fileEditorTab = createFileEditor( path );
310
        getTabs().add( fileEditorTab );
311
      }
312
313
      // Select the first file in the list.
314
      if( i == activeIndex ) {
315
        getSelectionModel().select( fileEditorTab );
316
      }
317
    }
318
  }
319
320
  /**
321
   * Returns a property that changes when a new definition file is opened.
322
   *
323
   * @return The path to a definition file that was opened.
324
   */
325
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
326
    return getOnOpenDefinitionFile().getReadOnlyProperty();
327
  }
328
329
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
330
    return mOpenDefinition;
331
  }
332
333
  /**
334
   * Called when the user has opened a definition file (using the file open
335
   * dialog box). This will replace the current set of definitions for the
336
   * active tab.
337
   *
338
   * @param definition The file to open.
339
   */
340
  private void openDefinition( final File definition ) {
341
    // TODO: Prevent reading this file twice when a new text document is opened.
342
    // (might be a matter of checking the value first).
343
    getOnOpenDefinitionFile().set( definition.toPath() );
344
  }
345
346
  /**
347
   * Called when the contents of the editor are to be saved.
348
   *
349
   * @param tab The tab containing content to save.
350
   * @return true The contents were saved (or needn't be saved).
351
   */
352
  public boolean saveEditor( final FileEditorTab tab ) {
353
    if( tab == null || !tab.isModified() ) {
354
      return true;
355
    }
356
357
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
358
  }
359
360
  /**
361
   * Opens the Save As dialog for the user to save the content under a new
362
   * path.
363
   *
364
   * @param tab The tab with contents to save.
365
   * @return true The contents were saved, or the tab was null.
366
   */
367
  public boolean saveEditorAs( final FileEditorTab tab ) {
368
    if( tab == null ) {
369
      return true;
370
    }
371
372
    getSelectionModel().select( tab );
373
374
    final FileChooser chooser = createFileChooser(
375
        "Dialog.file.choose.save.title" );
376
    final File file = chooser.showSaveDialog( getWindow() );
377
    if( file == null ) {
378
      return false;
379
    }
380
381
    saveLastDirectory( file );
382
    tab.setPath( file.toPath() );
383
384
    return tab.save();
385
  }
386
387
  void saveAllEditors() {
388
    for( final FileEditorTab fileEditor : getAllEditors() ) {
389
      saveEditor( fileEditor );
390
    }
391
  }
392
393
  /**
394
   * Answers whether the file has had modifications.
395
   *
396
   * @param tab THe tab to check for modifications.
397
   * @return false The file is unmodified.
398
   */
399
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
400
  boolean canCloseEditor( final FileEditorTab tab ) {
401
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
402
    canClose.set( true );
403
404
    if( tab.isModified() ) {
405
      final Notification message = getNotifyService().createNotification(
406
          Messages.get( "Alert.file.close.title" ),
407
          Messages.get( "Alert.file.close.text" ),
408
          tab.getText()
409
      );
410
411
      final Alert confirmSave = getNotifyService().createConfirmation(
412
          getWindow(), message );
413
414
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
415
416
      buttonType.ifPresent(
417
          save -> canClose.set(
418
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
419
          )
420
      );
421
    }
422
423
    return canClose.get();
424
  }
425
426
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
427
    if( tab == null ) {
428
      return true;
429
    }
430
431
    if( save ) {
432
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
433
      Event.fireEvent( tab, event );
434
435
      if( event.isConsumed() ) {
436
        return false;
437
      }
438
    }
439
440
    getTabs().remove( tab );
441
442
    if( tab.getOnClosed() != null ) {
443
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
444
    }
445
446
    return true;
447
  }
448
449
  boolean closeAllEditors() {
450
    final FileEditorTab[] allEditors = getAllEditors();
451
    final FileEditorTab activeEditor = getActiveFileEditor();
452
453
    // try to save active tab first because in case the user decides to cancel,
454
    // then it stays active
455
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
456
      return false;
457
    }
458
459
    // This should be called any time a tab changes.
460
    persistPreferences();
461
462
    // save modified tabs
463
    for( int i = 0; i < allEditors.length; i++ ) {
464
      final FileEditorTab fileEditor = allEditors[ i ];
465
466
      if( fileEditor == activeEditor ) {
467
        continue;
468
      }
469
470
      if( fileEditor.isModified() ) {
471
        // activate the modified tab to make its modified content visible to
472
        // the user
473
        getSelectionModel().select( i );
474
475
        if( !canCloseEditor( fileEditor ) ) {
476
          return false;
477
        }
478
      }
479
    }
480
481
    // Close all tabs.
482
    for( final FileEditorTab fileEditor : allEditors ) {
483
      if( !closeEditor( fileEditor, false ) ) {
484
        return false;
485
      }
486
    }
487
488
    return getTabs().isEmpty();
489
  }
490
491
  private FileEditorTab[] getAllEditors() {
492
    final ObservableList<Tab> tabs = getTabs();
493
    final int length = tabs.size();
494
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
495
496
    for( int i = 0; i < length; i++ ) {
497
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
498
    }
499
500
    return allEditors;
501
  }
502
503
  /**
504
   * Returns the file editor tab that has the given path.
505
   *
506
   * @return null No file editor tab for the given path was found.
507
   */
508
  private FileEditorTab findEditor( final Path path ) {
509
    for( final Tab tab : getTabs() ) {
510
      final FileEditorTab fileEditor = (FileEditorTab) tab;
511
512
      if( fileEditor.isPath( path ) ) {
513
        return fileEditor;
514
      }
515
    }
516
517
    return null;
518
  }
519
520
  /**
521
   * Opens a new {@link FileChooser} at the previously selected directory.
522
   *
523
   * @param key Message key from resource bundle.
524
   * @return {@link FileChooser} GUI allowing the user to pick a file.
525
   */
526
  private FileChooser createFileChooser( final String key ) {
527
    final FileChooser fileChooser = new FileChooser();
528
529
    fileChooser.setTitle( get( key ) );
530
    fileChooser.getExtensionFilters().addAll(
531
        createExtensionFilters() );
532
533
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
534
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
535
536
    if( !file.isDirectory() ) {
537
      file = new File( "." );
538
    }
539
540
    fileChooser.setInitialDirectory( file );
541
    return fileChooser;
542
  }
543
544
  private List<ExtensionFilter> createExtensionFilters() {
545
    final List<ExtensionFilter> list = new ArrayList<>();
546
547
    // TODO: Return a list of all properties that match the filter prefix.
548
    // This will allow dynamic filters to be added and removed just by
549
    // updating the properties file.
550
    list.add( createExtensionFilter( ALL ) );
551
    list.add( createExtensionFilter( SOURCE ) );
552
    list.add( createExtensionFilter( DEFINITION ) );
553
    list.add( createExtensionFilter( XML ) );
554
    return list;
555
  }
556
557
  /**
558
   * Returns a filter for file name extensions recognized by the application
559
   * that can be opened by the user.
560
   *
561
   * @param filetype Used to find the globbing pattern for extensions.
562
   * @return A filename filter suitable for use by a FileDialog instance.
563
   */
564
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
565
    final String tKey = String.format( "%s.title.%s",
566
                                       FILTER_EXTENSION_TITLES,
567
                                       filetype );
568
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
569
570
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
571
  }
572
573
  private void saveLastDirectory( final File file ) {
574
    getPreferences().put( "lastDirectory", file.getParent() );
575
  }
576
577
  public void initPreferences() {
578
    int activeIndex = 0;
579
580
    final Preferences preferences = getPreferences();
581
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
582
    final String activeFileName = preferences.get( "activeFile", null );
583
584
    final List<File> files = new ArrayList<>( fileNames.length );
585
586
    for( final String fileName : fileNames ) {
587
      final File file = new File( fileName );
588
589
      if( file.exists() ) {
590
        files.add( file );
591
592
        if( fileName.equals( activeFileName ) ) {
593
          activeIndex = files.size() - 1;
594
        }
595
      }
596
    }
597
598
    if( files.isEmpty() ) {
599
      newEditor();
600
    }
601
    else {
602
      openEditors( files, activeIndex );
603
    }
604
  }
605
606
  public void persistPreferences() {
607
    final var allEditors = getTabs();
608
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
609
610
    for( final var tab : allEditors ) {
611
      final var fileEditor = (FileEditorTab) tab;
612
      final var filePath = fileEditor.getPath();
613
614
      if( filePath != null ) {
615
        fileNames.add( filePath.toString() );
616
      }
617
    }
618
619
    final var preferences = getPreferences();
620
    Utils.putPrefsStrings( preferences,
621
                           "file",
622
                           fileNames.toArray( new String[ 0 ] ) );
623
624
    final var activeEditor = getActiveFileEditor();
625
    final var filePath = activeEditor == null ? null : activeEditor.getPath();
626
627
    if( filePath == null ) {
628
      preferences.remove( "activeFile" );
629
    }
630
    else {
631
      preferences.put( "activeFile", filePath.toString() );
632
    }
633
  }
634
635
  private List<String> getExtensions( final String key ) {
636
    return getSettings().getStringSettingList( key );
637
  }
638
639
  private Notifier getNotifyService() {
640
    return sNotifier;
641
  }
642
643
  private Settings getSettings() {
644
    return SETTINGS;
645
  }
646
647
  private Window getWindow() {
648
    return getScene().getWindow();
649
  }
650
651
  private Preferences getPreferences() {
652
    return sOptions.getState();
651653
  }
652654
}
M src/main/java/com/keenwrite/Main.java
4848
import static com.keenwrite.Bootstrap.APP_TITLE;
4949
import static com.keenwrite.Constants.*;
50
import static com.keenwrite.StatusBarNotifier.alert;
50
import static com.keenwrite.StatusBarNotifier.clue;
5151
import static java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment;
5252
import static java.awt.font.TextAttribute.*;
...
128128
              ge.registerFont( font.deriveFont( attributes ) );
129129
            } catch( final Exception e ) {
130
              alert( e );
130
              clue( e );
131131
            }
132132
          }
133133
      );
134134
    } catch( final Exception e ) {
135
      alert( e );
135
      clue( e );
136136
    }
137137
  }
M src/main/java/com/keenwrite/MainWindow.java
3737
import com.keenwrite.editors.EditorPane;
3838
import com.keenwrite.editors.markdown.MarkdownEditorPane;
39
import com.keenwrite.preferences.UserPreferences;
40
import com.keenwrite.preview.HTMLPreviewPane;
41
import com.keenwrite.exceptions.MissingFileException;
42
import com.keenwrite.processors.HtmlPreviewProcessor;
43
import com.keenwrite.processors.Processor;
44
import com.keenwrite.processors.ProcessorFactory;
45
import com.keenwrite.service.Options;
46
import com.keenwrite.service.Snitch;
47
import com.keenwrite.spelling.api.SpellCheckListener;
48
import com.keenwrite.spelling.api.SpellChecker;
49
import com.keenwrite.spelling.impl.PermissiveSpeller;
50
import com.keenwrite.spelling.impl.SymSpellSpeller;
51
import com.keenwrite.util.Action;
52
import com.keenwrite.util.ActionBuilder;
53
import com.keenwrite.util.ActionUtils;
54
import com.vladsch.flexmark.parser.Parser;
55
import com.vladsch.flexmark.util.ast.NodeVisitor;
56
import com.vladsch.flexmark.util.ast.VisitHandler;
57
import javafx.beans.binding.Bindings;
58
import javafx.beans.binding.BooleanBinding;
59
import javafx.beans.property.BooleanProperty;
60
import javafx.beans.property.SimpleBooleanProperty;
61
import javafx.beans.value.ChangeListener;
62
import javafx.beans.value.ObservableBooleanValue;
63
import javafx.beans.value.ObservableValue;
64
import javafx.collections.ListChangeListener.Change;
65
import javafx.collections.ObservableList;
66
import javafx.event.Event;
67
import javafx.event.EventHandler;
68
import javafx.geometry.Pos;
69
import javafx.scene.Node;
70
import javafx.scene.Scene;
71
import javafx.scene.control.*;
72
import javafx.scene.image.ImageView;
73
import javafx.scene.input.Clipboard;
74
import javafx.scene.input.ClipboardContent;
75
import javafx.scene.input.KeyEvent;
76
import javafx.scene.layout.BorderPane;
77
import javafx.scene.layout.VBox;
78
import javafx.scene.text.Text;
79
import javafx.stage.Window;
80
import javafx.stage.WindowEvent;
81
import javafx.util.Duration;
82
import org.apache.commons.lang3.SystemUtils;
83
import org.controlsfx.control.StatusBar;
84
import org.fxmisc.richtext.StyleClassedTextArea;
85
import org.fxmisc.richtext.model.StyleSpansBuilder;
86
import org.reactfx.value.Val;
87
88
import java.io.BufferedReader;
89
import java.io.InputStreamReader;
90
import java.nio.file.Path;
91
import java.util.*;
92
import java.util.concurrent.atomic.AtomicInteger;
93
import java.util.function.Consumer;
94
import java.util.function.Function;
95
import java.util.prefs.Preferences;
96
import java.util.stream.Collectors;
97
98
import static com.keenwrite.Bootstrap.APP_TITLE;
99
import static com.keenwrite.Constants.*;
100
import static com.keenwrite.Messages.get;
101
import static com.keenwrite.StatusBarNotifier.alert;
102
import static com.keenwrite.util.StageState.*;
103
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
104
import static java.nio.charset.StandardCharsets.UTF_8;
105
import static java.util.Collections.emptyList;
106
import static java.util.Collections.singleton;
107
import static javafx.application.Platform.runLater;
108
import static javafx.event.Event.fireEvent;
109
import static javafx.scene.control.Alert.AlertType.INFORMATION;
110
import static javafx.scene.input.KeyCode.ENTER;
111
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
112
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
113
114
/**
115
 * Main window containing a tab pane in the center for file editors.
116
 */
117
public class MainWindow implements Observer {
118
  /**
119
   * The {@code OPTIONS} variable must be declared before all other variables
120
   * to prevent subsequent initializations from failing due to missing user
121
   * preferences.
122
   */
123
  private static final Options sOptions = Services.load( Options.class );
124
  private static final Snitch SNITCH = Services.load( Snitch.class );
125
126
  private final Scene mScene;
127
  private final StatusBar mStatusBar;
128
  private final Text mLineNumberText;
129
  private final TextField mFindTextField;
130
  private final SpellChecker mSpellChecker;
131
132
  private final Object mMutex = new Object();
133
134
  /**
135
   * Prevents re-instantiation of processing classes.
136
   */
137
  private final Map<FileEditorTab, Processor<String>> mProcessors =
138
      new HashMap<>();
139
140
  private final Map<String, String> mResolvedMap =
141
      new HashMap<>( DEFAULT_MAP_SIZE );
142
143
  private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
144
      event -> rerender();
145
146
  /**
147
   * Called when the definition data is changed.
148
   */
149
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
150
      mTreeHandler = event -> {
151
    exportDefinitions( getDefinitionPath() );
152
    interpolateResolvedMap();
153
    rerender();
154
  };
155
156
  /**
157
   * Called to inject the selected item when the user presses ENTER in the
158
   * definition pane.
159
   */
160
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
161
      event -> {
162
        if( event.getCode() == ENTER ) {
163
          getDefinitionNameInjector().injectSelectedItem();
164
        }
165
      };
166
167
  private final ChangeListener<Integer> mCaretPositionListener =
168
      ( observable, oldPosition, newPosition ) -> {
169
        final FileEditorTab tab = getActiveFileEditorTab();
170
        final EditorPane pane = tab.getEditorPane();
171
        final StyleClassedTextArea editor = pane.getEditor();
172
173
        getLineNumberText().setText(
174
            get( STATUS_BAR_LINE,
175
                 editor.getCurrentParagraph() + 1,
176
                 editor.getParagraphs().size(),
177
                 editor.getCaretPosition()
178
            )
179
        );
180
      };
181
182
  private final ChangeListener<Integer> mCaretParagraphListener =
183
      ( observable, oldIndex, newIndex ) ->
184
          scrollToParagraph( newIndex, true );
185
186
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
187
  private final DefinitionPane mDefinitionPane = createDefinitionPane();
188
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
189
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
190
      mCaretPositionListener,
191
      mCaretParagraphListener );
192
193
  /**
194
   * Listens on the definition pane for double-click events.
195
   */
196
  private final DefinitionNameInjector mDefinitionNameInjector
197
      = new DefinitionNameInjector( mDefinitionPane );
198
199
  public MainWindow() {
200
    mStatusBar = createStatusBar();
201
    mLineNumberText = createLineNumberText();
202
    mFindTextField = createFindTextField();
203
    mScene = createScene();
204
    mSpellChecker = createSpellChecker();
205
206
    // Add the close request listener before the window is shown.
207
    initLayout();
208
    StatusBarNotifier.setStatusBar( mStatusBar );
209
  }
210
211
  /**
212
   * Called after the stage is shown.
213
   */
214
  public void init() {
215
    initFindInput();
216
    initSnitch();
217
    initDefinitionListener();
218
    initTabAddedListener();
219
    initTabChangedListener();
220
    initPreferences();
221
    initVariableNameInjector();
222
  }
223
224
  private void initLayout() {
225
    final var scene = getScene();
226
227
    scene.getStylesheets().add( STYLESHEET_SCENE );
228
    scene.windowProperty().addListener(
229
        ( unused, oldWindow, newWindow ) ->
230
            newWindow.setOnCloseRequest(
231
                e -> {
232
                  if( !getFileEditorPane().closeAllEditors() ) {
233
                    e.consume();
234
                  }
235
                }
236
            )
237
    );
238
  }
239
240
  /**
241
   * Initialize the find input text field to listen on F3, ENTER, and
242
   * ESCAPE key presses.
243
   */
244
  private void initFindInput() {
245
    final TextField input = getFindTextField();
246
247
    input.setOnKeyPressed( ( KeyEvent event ) -> {
248
      switch( event.getCode() ) {
249
        case F3:
250
        case ENTER:
251
          editFindNext();
252
          break;
253
        case F:
254
          if( !event.isControlDown() ) {
255
            break;
256
          }
257
        case ESCAPE:
258
          getStatusBar().setGraphic( null );
259
          getActiveFileEditorTab().getEditorPane().requestFocus();
260
          break;
261
      }
262
    } );
263
264
    // Remove when the input field loses focus.
265
    input.focusedProperty().addListener(
266
        ( focused, oldFocus, newFocus ) -> {
267
          if( !newFocus ) {
268
            getStatusBar().setGraphic( null );
269
          }
270
        }
271
    );
272
  }
273
274
  /**
275
   * Watch for changes to external files. In particular, this awaits
276
   * modifications to any XSL files associated with XML files being edited.
277
   * When
278
   * an XSL file is modified (external to the application), the snitch's ears
279
   * perk up and the file is reloaded. This keeps the XSL transformation up to
280
   * date with what's on the file system.
281
   */
282
  private void initSnitch() {
283
    SNITCH.addObserver( this );
284
  }
285
286
  /**
287
   * Listen for {@link FileEditorTabPane} to receive open definition file
288
   * event.
289
   */
290
  private void initDefinitionListener() {
291
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
292
        ( final ObservableValue<? extends Path> file,
293
          final Path oldPath, final Path newPath ) -> {
294
          openDefinitions( newPath );
295
          rerender();
296
        }
297
    );
298
  }
299
300
  /**
301
   * Re-instantiates all processors then re-renders the active tab. This
302
   * will refresh the resolved map, force R to re-initialize, and brute-force
303
   * XSLT file reloads.
304
   */
305
  private void rerender() {
306
    runLater(
307
        () -> {
308
          resetProcessors();
309
          renderActiveTab();
310
        }
311
    );
312
  }
313
314
  /**
315
   * When tabs are added, hook the various change listeners onto the new
316
   * tab sothat the preview pane refreshes as necessary.
317
   */
318
  private void initTabAddedListener() {
319
    final FileEditorTabPane editorPane = getFileEditorPane();
320
321
    // Make sure the text processor kicks off when new files are opened.
322
    final ObservableList<Tab> tabs = editorPane.getTabs();
323
324
    // Update the preview pane on tab changes.
325
    tabs.addListener(
326
        ( final Change<? extends Tab> change ) -> {
327
          while( change.next() ) {
328
            if( change.wasAdded() ) {
329
              // Multiple tabs can be added simultaneously.
330
              for( final Tab newTab : change.getAddedSubList() ) {
331
                final FileEditorTab tab = (FileEditorTab) newTab;
332
333
                initTextChangeListener( tab );
334
                initScrollEventListener( tab );
335
                initSpellCheckListener( tab );
336
//              initSyntaxListener( tab );
337
              }
338
            }
339
          }
340
        }
341
    );
342
  }
343
344
  private void initTextChangeListener( final FileEditorTab tab ) {
345
    tab.addTextChangeListener(
346
        ( __, ov, nv ) -> {
347
          process( tab );
348
          scrollToParagraph( getCurrentParagraphIndex() );
349
        }
350
    );
351
  }
352
353
  private void initScrollEventListener( final FileEditorTab tab ) {
354
    final var scrollPane = tab.getScrollPane();
355
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
356
357
    addShowListener( scrollPane, ( __ ) -> {
358
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
359
      handler.enabledProperty().bind( tab.selectedProperty() );
360
    } );
361
  }
362
363
  /**
364
   * Listen for changes to the any particular paragraph and perform a quick
365
   * spell check upon it. The style classes in the editor will be changed to
366
   * mark any spelling mistakes in the paragraph. The user may then interact
367
   * with any misspelled word (i.e., any piece of text that is marked) to
368
   * revise the spelling.
369
   *
370
   * @param tab The tab to spellcheck.
371
   */
372
  private void initSpellCheckListener( final FileEditorTab tab ) {
373
    final var editor = tab.getEditorPane().getEditor();
374
375
    // When the editor first appears, run a full spell check. This allows
376
    // spell checking while typing to be restricted to the active paragraph,
377
    // which is usually substantially smaller than the whole document.
378
    addShowListener(
379
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
380
    );
381
382
    // Use the plain text changes so that notifications of style changes
383
    // are suppressed. Checking against the identity ensures that only
384
    // new text additions or deletions trigger proofreading.
385
    editor.plainTextChanges()
386
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
387
388
      // Only perform a spell check on the current paragraph. The
389
      // entire document is processed once, when opened.
390
      final var offset = change.getPosition();
391
      final var position = editor.offsetToPosition( offset, Forward );
392
      final var paraId = position.getMajor();
393
      final var paragraph = editor.getParagraph( paraId );
394
      final var text = paragraph.getText();
395
396
      // Ensure that styles aren't doubled-up.
397
      editor.clearStyle( paraId );
398
399
      spellcheck( editor, text, paraId );
400
    } );
401
  }
402
403
  /**
404
   * Listen for new tab selection events.
405
   */
406
  private void initTabChangedListener() {
407
    final FileEditorTabPane editorPane = getFileEditorPane();
408
409
    // Update the preview pane changing tabs.
410
    editorPane.addTabSelectionListener(
411
        ( tabPane, oldTab, newTab ) -> {
412
          if( newTab == null ) {
413
            // Clear the preview pane when closing an editor. When the last
414
            // tab is closed, this ensures that the preview pane is empty.
415
            getPreviewPane().clear();
416
          }
417
          else {
418
            final var tab = (FileEditorTab) newTab;
419
            updateVariableNameInjector( tab );
420
            process( tab );
421
          }
422
        }
423
    );
424
  }
425
426
  /**
427
   * Reloads the preferences from the previous session.
428
   */
429
  private void initPreferences() {
430
    initDefinitionPane();
431
    getFileEditorPane().initPreferences();
432
    getUserPreferences().addSaveEventHandler( mRPreferencesListener );
433
  }
434
435
  private void initVariableNameInjector() {
436
    updateVariableNameInjector( getActiveFileEditorTab() );
437
  }
438
439
  /**
440
   * Calls the listener when the given node is shown for the first time. The
441
   * visible property is not the same as the initial showing event; visibility
442
   * can be triggered numerous times (such as going off screen).
443
   * <p>
444
   * This is called, for example, before the drag handler can be attached,
445
   * because the scrollbar for the text editor pane must be visible.
446
   * </p>
447
   *
448
   * @param node     The node to watch for showing.
449
   * @param consumer The consumer to invoke when the event fires.
450
   */
451
  private void addShowListener(
452
      final Node node, final Consumer<Void> consumer ) {
453
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
454
        runLater( () -> {
455
          if( newShow != null && newShow ) {
456
            try {
457
              consumer.accept( null );
458
            } catch( final Exception ex ) {
459
              alert( ex );
460
            }
461
          }
462
        } );
463
464
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
465
       .flatMap( Window::showingProperty )
466
       .addListener( listener );
467
  }
468
469
  private void scrollToParagraph( final int id ) {
470
    scrollToParagraph( id, false );
471
  }
472
473
  /**
474
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
475
   *              exist.
476
   * @param force {@code true} means to force scrolling immediately, which
477
   *              should only be attempted when it is known that the document
478
   *              has been fully rendered. Otherwise the internal map of ID
479
   *              attributes will be incomplete and scrolling will flounder.
480
   */
481
  private void scrollToParagraph( final int id, final boolean force ) {
482
    synchronized( mMutex ) {
483
      final var previewPane = getPreviewPane();
484
      final var scrollPane = previewPane.getScrollPane();
485
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
486
487
      if( force ) {
488
        previewPane.scrollTo( approxId );
489
      }
490
      else {
491
        previewPane.tryScrollTo( approxId );
492
      }
493
494
      scrollPane.repaint();
495
    }
496
  }
497
498
  private void updateVariableNameInjector( final FileEditorTab tab ) {
499
    getDefinitionNameInjector().addListener( tab );
500
  }
501
502
  /**
503
   * Called whenever the preview pane becomes out of sync with the file editor
504
   * tab. This can be called when the text changes, the caret paragraph
505
   * changes, or the file tab changes.
506
   *
507
   * @param tab The file editor tab that has been changed in some fashion.
508
   */
509
  private void process( final FileEditorTab tab ) {
510
    if( tab != null ) {
511
      getPreviewPane().setPath( tab.getPath() );
512
513
      final Processor<String> processor = getProcessors().computeIfAbsent(
514
          tab, p -> createProcessors( tab )
515
      );
516
517
      try {
518
        processChain( processor, tab.getEditorText() );
519
      } catch( final Exception ex ) {
520
        alert( ex );
521
      }
522
    }
523
  }
524
525
  /**
526
   * Executes the processing chain, operating on the given string.
527
   *
528
   * @param handler The first processor in the chain to call.
529
   * @param text    The initial value of the text to process.
530
   * @return The final value of the text that was processed by the chain.
531
   */
532
  private String processChain( Processor<String> handler, String text ) {
533
    while( handler != null && text != null ) {
534
      text = handler.apply( text );
535
      handler = handler.next();
536
    }
537
538
    return text;
539
  }
540
541
  private void renderActiveTab() {
542
    process( getActiveFileEditorTab() );
543
  }
544
545
  /**
546
   * Called when a definition source is opened.
547
   *
548
   * @param path Path to the definition source that was opened.
549
   */
550
  private void openDefinitions( final Path path ) {
551
    try {
552
      final var ds = createDefinitionSource( path );
553
      setDefinitionSource( ds );
554
555
      final var prefs = getUserPreferences();
556
      prefs.definitionPathProperty().setValue( path.toFile() );
557
      prefs.save();
558
559
      final var tooltipPath = new Tooltip( path.toString() );
560
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
561
562
      final var pane = getDefinitionPane();
563
      pane.update( ds );
564
      pane.addTreeChangeHandler( mTreeHandler );
565
      pane.addKeyEventHandler( mDefinitionKeyHandler );
566
      pane.filenameProperty().setValue( path.getFileName().toString() );
567
      pane.setTooltip( tooltipPath );
568
569
      interpolateResolvedMap();
570
    } catch( final Exception ex ) {
571
      alert( ex );
572
    }
573
  }
574
575
  private void exportDefinitions( final Path path ) {
576
    try {
577
      final var pane = getDefinitionPane();
578
      final var root = pane.getTreeView().getRoot();
579
      final var problemChild = pane.isTreeWellFormed();
580
581
      if( problemChild == null ) {
582
        getDefinitionSource().getTreeAdapter().export( root, path );
583
      }
584
      else {
585
        alert( "yaml.error.tree.form", problemChild.getValue() );
586
      }
587
    } catch( final Exception ex ) {
588
      alert( ex );
589
    }
590
  }
591
592
  private void interpolateResolvedMap() {
593
    final var treeMap = getDefinitionPane().toMap();
594
    final var map = new HashMap<>( treeMap );
595
    MapInterpolator.interpolate( map );
596
597
    getResolvedMap().clear();
598
    getResolvedMap().putAll( map );
599
  }
600
601
  private void initDefinitionPane() {
602
    openDefinitions( getDefinitionPath() );
603
  }
604
605
  //---- File actions -------------------------------------------------------
606
607
  /**
608
   * Called when an {@link Observable} instance has changed. This is called
609
   * by both the {@link Snitch} service and the notify service. The @link
610
   * Snitch} service can be called for different file types, including
611
   * {@link DefinitionSource} instances.
612
   *
613
   * @param observable The observed instance.
614
   * @param value      The noteworthy item.
615
   */
616
  @Override
617
  public void update( final Observable observable, final Object value ) {
618
    if( value instanceof Path && observable instanceof Snitch ) {
619
      updateSelectedTab();
620
    }
621
  }
622
623
  /**
624
   * Called when a file has been modified.
625
   */
626
  private void updateSelectedTab() {
627
    rerender();
628
  }
629
630
  /**
631
   * After resetting the processors, they will refresh anew to be up-to-date
632
   * with the files (text and definition) currently loaded into the editor.
633
   */
634
  private void resetProcessors() {
635
    getProcessors().clear();
636
  }
637
638
  //---- File actions -------------------------------------------------------
639
640
  private void fileNew() {
641
    getFileEditorPane().newEditor();
642
  }
643
644
  private void fileOpen() {
645
    getFileEditorPane().openFileDialog();
646
  }
647
648
  private void fileClose() {
649
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
650
  }
651
652
  /**
653
   * TODO: Upon closing, first remove the tab change listeners. (There's no
654
   * need to re-render each tab when all are being closed.)
655
   */
656
  private void fileCloseAll() {
657
    getFileEditorPane().closeAllEditors();
658
  }
659
660
  private void fileSave() {
661
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
662
  }
663
664
  private void fileSaveAs() {
665
    final FileEditorTab editor = getActiveFileEditorTab();
666
    getFileEditorPane().saveEditorAs( editor );
667
    getProcessors().remove( editor );
668
669
    try {
670
      process( editor );
671
    } catch( final Exception ex ) {
672
      alert( ex );
673
    }
674
  }
675
676
  private void fileSaveAll() {
677
    getFileEditorPane().saveAllEditors();
678
  }
679
680
  private void fileExit() {
681
    final Window window = getWindow();
682
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
683
  }
684
685
  //---- Edit actions -------------------------------------------------------
686
687
  /**
688
   * Transform the Markdown into HTML then copy that HTML into the copy
689
   * buffer.
690
   */
691
  private void copyHtml() {
692
    final var markdown = getActiveEditorPane().getText();
693
    final var processors = createProcessorFactory().createProcessors(
694
        getActiveFileEditorTab()
695
    );
696
697
    final var chain = processors.remove( HtmlPreviewProcessor.class );
698
699
    final String html = processChain( chain, markdown );
700
701
    final Clipboard clipboard = Clipboard.getSystemClipboard();
702
    final ClipboardContent content = new ClipboardContent();
703
    content.putString( html );
704
    clipboard.setContent( content );
705
  }
706
707
  /**
708
   * Used to find text in the active file editor window.
709
   */
710
  private void editFind() {
711
    final TextField input = getFindTextField();
712
    getStatusBar().setGraphic( input );
713
    input.requestFocus();
714
  }
715
716
  public void editFindNext() {
717
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
718
  }
719
720
  public void editPreferences() {
721
    getUserPreferences().show();
722
  }
723
724
  //---- Insert actions -----------------------------------------------------
725
726
  /**
727
   * Delegates to the active editor to handle wrapping the current text
728
   * selection with leading and trailing strings.
729
   *
730
   * @param leading  The string to put before the selection.
731
   * @param trailing The string to put after the selection.
732
   */
733
  private void insertMarkdown(
734
      final String leading, final String trailing ) {
735
    getActiveEditorPane().surroundSelection( leading, trailing );
736
  }
737
738
  private void insertMarkdown(
739
      final String leading, final String trailing, final String hint ) {
740
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
741
  }
742
743
  //---- View actions -------------------------------------------------------
744
745
  private void viewRefresh() {
746
    rerender();
747
  }
748
749
  //---- Help actions -------------------------------------------------------
750
751
  private void helpAbout() {
752
    final Alert alert = new Alert( INFORMATION );
753
    alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
754
    alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
755
    alert.setContentText( get( "Dialog.about.content" ) );
756
    alert.setGraphic( new ImageView( ICON_DIALOG ) );
757
    alert.initOwner( getWindow() );
758
759
    alert.showAndWait();
760
  }
761
762
  //---- Member creators ----------------------------------------------------
763
764
  private SpellChecker createSpellChecker() {
765
    try {
766
      final Collection<String> lexicon = readLexicon( "en.txt" );
767
      return SymSpellSpeller.forLexicon( lexicon );
768
    } catch( final Exception ex ) {
769
      alert( ex );
770
      return new PermissiveSpeller();
771
    }
772
  }
773
774
  /**
775
   * Factory to create processors that are suited to different file types.
776
   *
777
   * @param tab The tab that is subjected to processing.
778
   * @return A processor suited to the file type specified by the tab's path.
779
   */
780
  private Processor<String> createProcessors( final FileEditorTab tab ) {
781
    return createProcessorFactory().createProcessors( tab );
782
  }
783
784
  private ProcessorFactory createProcessorFactory() {
785
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
786
  }
787
788
  private DefinitionPane createDefinitionPane() {
789
    return new DefinitionPane();
790
  }
791
792
  private HTMLPreviewPane createHTMLPreviewPane() {
793
    return new HTMLPreviewPane();
794
  }
795
796
  private DefinitionSource createDefaultDefinitionSource() {
797
    return new YamlDefinitionSource( getDefinitionPath() );
798
  }
799
800
  private DefinitionSource createDefinitionSource( final Path path ) {
801
    try {
802
      return createDefinitionFactory().createDefinitionSource( path );
803
    } catch( final Exception ex ) {
804
      alert( ex );
805
      return createDefaultDefinitionSource();
806
    }
807
  }
808
809
  private TextField createFindTextField() {
810
    return new TextField();
811
  }
812
813
  private DefinitionFactory createDefinitionFactory() {
814
    return new DefinitionFactory();
815
  }
816
817
  private StatusBar createStatusBar() {
818
    return new StatusBar();
819
  }
820
821
  private Scene createScene() {
822
    final SplitPane splitPane = new SplitPane(
823
        getDefinitionPane(),
824
        getFileEditorPane(),
825
        getPreviewPane() );
826
827
    splitPane.setDividerPositions(
828
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
829
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
830
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
831
832
    getDefinitionPane().prefHeightProperty()
833
                       .bind( splitPane.heightProperty() );
834
835
    final BorderPane borderPane = new BorderPane();
836
    borderPane.setPrefSize( 1280, 800 );
837
    borderPane.setTop( createMenuBar() );
838
    borderPane.setBottom( getStatusBar() );
839
    borderPane.setCenter( splitPane );
840
841
    final VBox statusBar = new VBox();
842
    statusBar.setAlignment( Pos.BASELINE_CENTER );
843
    statusBar.getChildren().add( getLineNumberText() );
844
    getStatusBar().getRightItems().add( statusBar );
845
846
    // Force preview pane refresh on Windows.
847
    if( SystemUtils.IS_OS_WINDOWS ) {
848
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
849
          ( l, oValue, nValue ) -> runLater(
850
              () -> getPreviewPane().getScrollPane().repaint()
851
          )
852
      );
853
    }
854
855
    return new Scene( borderPane );
856
  }
857
858
  private Text createLineNumberText() {
859
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
860
  }
861
862
  private Node createMenuBar() {
863
    final BooleanBinding activeFileEditorIsNull =
864
        getFileEditorPane().activeFileEditorProperty().isNull();
865
866
    // File actions
867
    final Action fileNewAction = new ActionBuilder()
868
        .setText( "Main.menu.file.new" )
869
        .setAccelerator( "Shortcut+N" )
870
        .setIcon( FILE_ALT )
871
        .setAction( e -> fileNew() )
872
        .build();
873
    final Action fileOpenAction = new ActionBuilder()
874
        .setText( "Main.menu.file.open" )
875
        .setAccelerator( "Shortcut+O" )
876
        .setIcon( FOLDER_OPEN_ALT )
877
        .setAction( e -> fileOpen() )
878
        .build();
879
    final Action fileCloseAction = new ActionBuilder()
880
        .setText( "Main.menu.file.close" )
881
        .setAccelerator( "Shortcut+W" )
882
        .setAction( e -> fileClose() )
883
        .setDisable( activeFileEditorIsNull )
884
        .build();
885
    final Action fileCloseAllAction = new ActionBuilder()
886
        .setText( "Main.menu.file.close_all" )
887
        .setAction( e -> fileCloseAll() )
888
        .setDisable( activeFileEditorIsNull )
889
        .build();
890
    final Action fileSaveAction = new ActionBuilder()
891
        .setText( "Main.menu.file.save" )
892
        .setAccelerator( "Shortcut+S" )
893
        .setIcon( FLOPPY_ALT )
894
        .setAction( e -> fileSave() )
895
        .setDisable( createActiveBooleanProperty(
896
            FileEditorTab::modifiedProperty ).not() )
897
        .build();
898
    final Action fileSaveAsAction = new ActionBuilder()
899
        .setText( "Main.menu.file.save_as" )
900
        .setAction( e -> fileSaveAs() )
901
        .setDisable( activeFileEditorIsNull )
902
        .build();
903
    final Action fileSaveAllAction = new ActionBuilder()
904
        .setText( "Main.menu.file.save_all" )
905
        .setAccelerator( "Shortcut+Shift+S" )
906
        .setAction( e -> fileSaveAll() )
907
        .setDisable( Bindings.not(
908
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
909
        .build();
910
    final Action fileExitAction = new ActionBuilder()
911
        .setText( "Main.menu.file.exit" )
912
        .setAction( e -> fileExit() )
913
        .build();
914
915
    // Edit actions
916
    final Action editCopyHtmlAction = new ActionBuilder()
917
        .setText( "Main.menu.edit.copy.html" )
918
        .setIcon( HTML5 )
919
        .setAction( e -> copyHtml() )
920
        .setDisable( activeFileEditorIsNull )
921
        .build();
922
923
    final Action editUndoAction = new ActionBuilder()
924
        .setText( "Main.menu.edit.undo" )
925
        .setAccelerator( "Shortcut+Z" )
926
        .setIcon( UNDO )
927
        .setAction( e -> getActiveEditorPane().undo() )
928
        .setDisable( createActiveBooleanProperty(
929
            FileEditorTab::canUndoProperty ).not() )
930
        .build();
931
    final Action editRedoAction = new ActionBuilder()
932
        .setText( "Main.menu.edit.redo" )
933
        .setAccelerator( "Shortcut+Y" )
934
        .setIcon( REPEAT )
935
        .setAction( e -> getActiveEditorPane().redo() )
936
        .setDisable( createActiveBooleanProperty(
937
            FileEditorTab::canRedoProperty ).not() )
938
        .build();
939
940
    final Action editCutAction = new ActionBuilder()
941
        .setText( "Main.menu.edit.cut" )
942
        .setAccelerator( "Shortcut+X" )
943
        .setIcon( CUT )
944
        .setAction( e -> getActiveEditorPane().cut() )
945
        .setDisable( activeFileEditorIsNull )
946
        .build();
947
    final Action editCopyAction = new ActionBuilder()
948
        .setText( "Main.menu.edit.copy" )
949
        .setAccelerator( "Shortcut+C" )
950
        .setIcon( COPY )
951
        .setAction( e -> getActiveEditorPane().copy() )
952
        .setDisable( activeFileEditorIsNull )
953
        .build();
954
    final Action editPasteAction = new ActionBuilder()
955
        .setText( "Main.menu.edit.paste" )
956
        .setAccelerator( "Shortcut+V" )
957
        .setIcon( PASTE )
958
        .setAction( e -> getActiveEditorPane().paste() )
959
        .setDisable( activeFileEditorIsNull )
960
        .build();
961
    final Action editSelectAllAction = new ActionBuilder()
962
        .setText( "Main.menu.edit.selectAll" )
963
        .setAccelerator( "Shortcut+A" )
964
        .setAction( e -> getActiveEditorPane().selectAll() )
965
        .setDisable( activeFileEditorIsNull )
966
        .build();
967
968
    final Action editFindAction = new ActionBuilder()
969
        .setText( "Main.menu.edit.find" )
970
        .setAccelerator( "Ctrl+F" )
971
        .setIcon( SEARCH )
972
        .setAction( e -> editFind() )
973
        .setDisable( activeFileEditorIsNull )
974
        .build();
975
    final Action editFindNextAction = new ActionBuilder()
976
        .setText( "Main.menu.edit.find.next" )
977
        .setAccelerator( "F3" )
978
        .setIcon( null )
979
        .setAction( e -> editFindNext() )
980
        .setDisable( activeFileEditorIsNull )
981
        .build();
982
    final Action editPreferencesAction = new ActionBuilder()
983
        .setText( "Main.menu.edit.preferences" )
984
        .setAccelerator( "Ctrl+Alt+S" )
985
        .setAction( e -> editPreferences() )
986
        .build();
987
988
    // Format actions
989
    final Action formatBoldAction = new ActionBuilder()
990
        .setText( "Main.menu.format.bold" )
991
        .setAccelerator( "Shortcut+B" )
992
        .setIcon( BOLD )
993
        .setAction( e -> insertMarkdown( "**", "**" ) )
994
        .setDisable( activeFileEditorIsNull )
995
        .build();
996
    final Action formatItalicAction = new ActionBuilder()
997
        .setText( "Main.menu.format.italic" )
998
        .setAccelerator( "Shortcut+I" )
999
        .setIcon( ITALIC )
1000
        .setAction( e -> insertMarkdown( "*", "*" ) )
1001
        .setDisable( activeFileEditorIsNull )
1002
        .build();
1003
    final Action formatSuperscriptAction = new ActionBuilder()
1004
        .setText( "Main.menu.format.superscript" )
1005
        .setAccelerator( "Shortcut+[" )
1006
        .setIcon( SUPERSCRIPT )
1007
        .setAction( e -> insertMarkdown( "^", "^" ) )
1008
        .setDisable( activeFileEditorIsNull )
1009
        .build();
1010
    final Action formatSubscriptAction = new ActionBuilder()
1011
        .setText( "Main.menu.format.subscript" )
1012
        .setAccelerator( "Shortcut+]" )
1013
        .setIcon( SUBSCRIPT )
1014
        .setAction( e -> insertMarkdown( "~", "~" ) )
1015
        .setDisable( activeFileEditorIsNull )
1016
        .build();
1017
    final Action formatStrikethroughAction = new ActionBuilder()
1018
        .setText( "Main.menu.format.strikethrough" )
1019
        .setAccelerator( "Shortcut+T" )
1020
        .setIcon( STRIKETHROUGH )
1021
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1022
        .setDisable( activeFileEditorIsNull )
1023
        .build();
1024
1025
    // Insert actions
1026
    final Action insertBlockquoteAction = new ActionBuilder()
1027
        .setText( "Main.menu.insert.blockquote" )
1028
        .setAccelerator( "Ctrl+Q" )
1029
        .setIcon( QUOTE_LEFT )
1030
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1031
        .setDisable( activeFileEditorIsNull )
1032
        .build();
1033
    final Action insertCodeAction = new ActionBuilder()
1034
        .setText( "Main.menu.insert.code" )
1035
        .setAccelerator( "Shortcut+K" )
1036
        .setIcon( CODE )
1037
        .setAction( e -> insertMarkdown( "`", "`" ) )
1038
        .setDisable( activeFileEditorIsNull )
1039
        .build();
1040
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1041
        .setText( "Main.menu.insert.fenced_code_block" )
1042
        .setAccelerator( "Shortcut+Shift+K" )
1043
        .setIcon( FILE_CODE_ALT )
1044
        .setAction( e -> insertMarkdown(
1045
            "\n\n```\n",
1046
            "\n```\n\n",
1047
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1048
        .setDisable( activeFileEditorIsNull )
1049
        .build();
1050
    final Action insertLinkAction = new ActionBuilder()
1051
        .setText( "Main.menu.insert.link" )
1052
        .setAccelerator( "Shortcut+L" )
1053
        .setIcon( LINK )
1054
        .setAction( e -> getActiveEditorPane().insertLink() )
1055
        .setDisable( activeFileEditorIsNull )
1056
        .build();
1057
    final Action insertImageAction = new ActionBuilder()
1058
        .setText( "Main.menu.insert.image" )
1059
        .setAccelerator( "Shortcut+G" )
1060
        .setIcon( PICTURE_ALT )
1061
        .setAction( e -> getActiveEditorPane().insertImage() )
1062
        .setDisable( activeFileEditorIsNull )
1063
        .build();
1064
1065
    // Number of heading actions (H1 ... H3)
1066
    final int HEADINGS = 3;
1067
    final Action[] headings = new Action[ HEADINGS ];
1068
1069
    for( int i = 1; i <= HEADINGS; i++ ) {
1070
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1071
      final String markup = String.format( "%n%n%s ", hashes );
1072
      final String text = "Main.menu.insert.heading." + i;
1073
      final String accelerator = "Shortcut+" + i;
1074
      final String prompt = text + ".prompt";
1075
1076
      headings[ i - 1 ] = new ActionBuilder()
1077
          .setText( text )
1078
          .setAccelerator( accelerator )
1079
          .setIcon( HEADER )
1080
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1081
          .setDisable( activeFileEditorIsNull )
1082
          .build();
1083
    }
1084
1085
    final Action insertUnorderedListAction = new ActionBuilder()
1086
        .setText( "Main.menu.insert.unordered_list" )
1087
        .setAccelerator( "Shortcut+U" )
1088
        .setIcon( LIST_UL )
1089
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1090
        .setDisable( activeFileEditorIsNull )
1091
        .build();
1092
    final Action insertOrderedListAction = new ActionBuilder()
1093
        .setText( "Main.menu.insert.ordered_list" )
1094
        .setAccelerator( "Shortcut+Shift+O" )
1095
        .setIcon( LIST_OL )
1096
        .setAction( e -> insertMarkdown(
1097
            "\n\n1. ", "" ) )
1098
        .setDisable( activeFileEditorIsNull )
1099
        .build();
1100
    final Action insertHorizontalRuleAction = new ActionBuilder()
1101
        .setText( "Main.menu.insert.horizontal_rule" )
1102
        .setAccelerator( "Shortcut+H" )
1103
        .setAction( e -> insertMarkdown(
1104
            "\n\n---\n\n", "" ) )
1105
        .setDisable( activeFileEditorIsNull )
1106
        .build();
1107
1108
    // Definition actions
1109
    final Action definitionCreateAction = new ActionBuilder()
1110
        .setText( "Main.menu.definition.create" )
1111
        .setIcon( TREE )
1112
        .setAction( e -> getDefinitionPane().addItem() )
1113
        .build();
1114
    final Action definitionInsertAction = new ActionBuilder()
1115
        .setText( "Main.menu.definition.insert" )
1116
        .setAccelerator( "Ctrl+Space" )
1117
        .setIcon( STAR )
1118
        .setAction( e -> definitionInsert() )
1119
        .build();
1120
1121
    // Help actions
1122
    final Action helpAboutAction = new ActionBuilder()
1123
        .setText( "Main.menu.help.about" )
1124
        .setAction( e -> helpAbout() )
1125
        .build();
1126
1127
    //---- MenuBar ----
1128
1129
    // File Menu
1130
    final var fileMenu = ActionUtils.createMenu(
1131
        get( "Main.menu.file" ),
1132
        fileNewAction,
1133
        fileOpenAction,
1134
        null,
1135
        fileCloseAction,
1136
        fileCloseAllAction,
1137
        null,
1138
        fileSaveAction,
1139
        fileSaveAsAction,
1140
        fileSaveAllAction,
1141
        null,
1142
        fileExitAction );
1143
1144
    // Edit Menu
1145
    final var editMenu = ActionUtils.createMenu(
1146
        get( "Main.menu.edit" ),
1147
        editCopyHtmlAction,
1148
        null,
1149
        editUndoAction,
1150
        editRedoAction,
1151
        null,
1152
        editCutAction,
1153
        editCopyAction,
1154
        editPasteAction,
1155
        editSelectAllAction,
1156
        null,
1157
        editFindAction,
1158
        editFindNextAction,
1159
        null,
1160
        editPreferencesAction );
1161
1162
    // Format Menu
1163
    final var formatMenu = ActionUtils.createMenu(
1164
        get( "Main.menu.format" ),
1165
        formatBoldAction,
1166
        formatItalicAction,
1167
        formatSuperscriptAction,
1168
        formatSubscriptAction,
1169
        formatStrikethroughAction
1170
    );
1171
1172
    // Insert Menu
1173
    final var insertMenu = ActionUtils.createMenu(
1174
        get( "Main.menu.insert" ),
1175
        insertBlockquoteAction,
1176
        insertCodeAction,
1177
        insertFencedCodeBlockAction,
1178
        null,
1179
        insertLinkAction,
1180
        insertImageAction,
1181
        null,
1182
        headings[ 0 ],
1183
        headings[ 1 ],
1184
        headings[ 2 ],
1185
        null,
1186
        insertUnorderedListAction,
1187
        insertOrderedListAction,
1188
        insertHorizontalRuleAction
1189
    );
1190
1191
    // Definition Menu
1192
    final var definitionMenu = ActionUtils.createMenu(
1193
        get( "Main.menu.definition" ),
1194
        definitionCreateAction,
1195
        definitionInsertAction );
1196
1197
    // Help Menu
1198
    final var helpMenu = ActionUtils.createMenu(
1199
        get( "Main.menu.help" ),
1200
        helpAboutAction );
1201
1202
    //---- MenuBar ----
1203
    final var menuBar = new MenuBar(
1204
        fileMenu,
1205
        editMenu,
1206
        formatMenu,
1207
        insertMenu,
1208
        definitionMenu,
1209
        helpMenu );
1210
1211
    //---- ToolBar ----
1212
    final var toolBar = ActionUtils.createToolBar(
1213
        fileNewAction,
1214
        fileOpenAction,
1215
        fileSaveAction,
1216
        null,
1217
        editUndoAction,
1218
        editRedoAction,
1219
        editCutAction,
1220
        editCopyAction,
1221
        editPasteAction,
1222
        null,
1223
        formatBoldAction,
1224
        formatItalicAction,
1225
        formatSuperscriptAction,
1226
        formatSubscriptAction,
1227
        insertBlockquoteAction,
1228
        insertCodeAction,
1229
        insertFencedCodeBlockAction,
1230
        null,
1231
        insertLinkAction,
1232
        insertImageAction,
1233
        null,
1234
        headings[ 0 ],
1235
        null,
39
import com.keenwrite.exceptions.MissingFileException;
40
import com.keenwrite.preferences.UserPreferences;
41
import com.keenwrite.preview.HTMLPreviewPane;
42
import com.keenwrite.processors.Processor;
43
import com.keenwrite.processors.ProcessorContext;
44
import com.keenwrite.processors.ProcessorFactory;
45
import com.keenwrite.processors.markdown.MarkdownProcessor;
46
import com.keenwrite.service.Options;
47
import com.keenwrite.service.Snitch;
48
import com.keenwrite.spelling.api.SpellCheckListener;
49
import com.keenwrite.spelling.api.SpellChecker;
50
import com.keenwrite.spelling.impl.PermissiveSpeller;
51
import com.keenwrite.spelling.impl.SymSpellSpeller;
52
import com.keenwrite.util.Action;
53
import com.keenwrite.util.ActionBuilder;
54
import com.keenwrite.util.ActionUtils;
55
import com.keenwrite.util.SeparatorAction;
56
import com.vladsch.flexmark.parser.Parser;
57
import com.vladsch.flexmark.util.ast.NodeVisitor;
58
import com.vladsch.flexmark.util.ast.VisitHandler;
59
import javafx.beans.binding.Bindings;
60
import javafx.beans.binding.BooleanBinding;
61
import javafx.beans.property.BooleanProperty;
62
import javafx.beans.property.SimpleBooleanProperty;
63
import javafx.beans.value.ChangeListener;
64
import javafx.beans.value.ObservableBooleanValue;
65
import javafx.beans.value.ObservableValue;
66
import javafx.collections.ListChangeListener.Change;
67
import javafx.collections.ObservableList;
68
import javafx.event.Event;
69
import javafx.event.EventHandler;
70
import javafx.geometry.Pos;
71
import javafx.scene.Node;
72
import javafx.scene.Scene;
73
import javafx.scene.control.*;
74
import javafx.scene.image.ImageView;
75
import javafx.scene.input.KeyEvent;
76
import javafx.scene.layout.BorderPane;
77
import javafx.scene.layout.VBox;
78
import javafx.scene.text.Text;
79
import javafx.stage.FileChooser;
80
import javafx.stage.Window;
81
import javafx.stage.WindowEvent;
82
import javafx.util.Duration;
83
import org.apache.commons.lang3.SystemUtils;
84
import org.controlsfx.control.StatusBar;
85
import org.fxmisc.richtext.StyleClassedTextArea;
86
import org.fxmisc.richtext.model.StyleSpansBuilder;
87
import org.reactfx.value.Val;
88
89
import java.io.BufferedReader;
90
import java.io.File;
91
import java.io.IOException;
92
import java.io.InputStreamReader;
93
import java.nio.file.Path;
94
import java.util.*;
95
import java.util.concurrent.atomic.AtomicInteger;
96
import java.util.function.Consumer;
97
import java.util.function.Function;
98
import java.util.prefs.Preferences;
99
import java.util.stream.Collectors;
100
101
import static com.keenwrite.Bootstrap.APP_TITLE;
102
import static com.keenwrite.Constants.*;
103
import static com.keenwrite.ExportFormat.*;
104
import static com.keenwrite.Messages.get;
105
import static com.keenwrite.StatusBarNotifier.clue;
106
import static com.keenwrite.processors.ProcessorFactory.processChain;
107
import static com.keenwrite.util.StageState.*;
108
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
109
import static java.nio.charset.StandardCharsets.UTF_8;
110
import static java.nio.file.Files.writeString;
111
import static java.util.Collections.emptyList;
112
import static java.util.Collections.singleton;
113
import static javafx.application.Platform.runLater;
114
import static javafx.event.Event.fireEvent;
115
import static javafx.scene.control.Alert.AlertType.INFORMATION;
116
import static javafx.scene.input.KeyCode.ENTER;
117
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
118
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
119
120
/**
121
 * Main window containing a tab pane in the center for file editors.
122
 */
123
public class MainWindow implements Observer {
124
  /**
125
   * The {@code OPTIONS} variable must be declared before all other variables
126
   * to prevent subsequent initializations from failing due to missing user
127
   * preferences.
128
   */
129
  private static final Options sOptions = Services.load( Options.class );
130
  private static final Snitch SNITCH = Services.load( Snitch.class );
131
132
  private final Scene mScene;
133
  private final StatusBar mStatusBar;
134
  private final Text mLineNumberText;
135
  private final TextField mFindTextField;
136
  private final SpellChecker mSpellChecker;
137
138
  private final Object mMutex = new Object();
139
140
  /**
141
   * Prevents re-instantiation of processing classes.
142
   */
143
  private final Map<FileEditorTab, Processor<String>> mProcessors =
144
      new HashMap<>();
145
146
  private final Map<String, String> mResolvedMap =
147
      new HashMap<>( DEFAULT_MAP_SIZE );
148
149
  private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
150
      event -> rerender();
151
152
  /**
153
   * Called when the definition data is changed.
154
   */
155
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
156
      mTreeHandler = event -> {
157
    exportDefinitions( getDefinitionPath() );
158
    interpolateResolvedMap();
159
    rerender();
160
  };
161
162
  /**
163
   * Called to inject the selected item when the user presses ENTER in the
164
   * definition pane.
165
   */
166
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
167
      event -> {
168
        if( event.getCode() == ENTER ) {
169
          getDefinitionNameInjector().injectSelectedItem();
170
        }
171
      };
172
173
  private final ChangeListener<Integer> mCaretPositionListener =
174
      ( observable, oldPosition, newPosition ) -> {
175
        final FileEditorTab tab = getActiveFileEditorTab();
176
        final EditorPane pane = tab.getEditorPane();
177
        final StyleClassedTextArea editor = pane.getEditor();
178
179
        getLineNumberText().setText(
180
            get( STATUS_BAR_LINE,
181
                 editor.getCurrentParagraph() + 1,
182
                 editor.getParagraphs().size(),
183
                 editor.getCaretPosition()
184
            )
185
        );
186
      };
187
188
  private final ChangeListener<Integer> mCaretParagraphListener =
189
      ( observable, oldIndex, newIndex ) ->
190
          scrollToParagraph( newIndex, true );
191
192
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
193
  private final DefinitionPane mDefinitionPane = createDefinitionPane();
194
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
195
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
196
      mCaretPositionListener,
197
      mCaretParagraphListener );
198
199
  /**
200
   * Listens on the definition pane for double-click events.
201
   */
202
  private final DefinitionNameInjector mDefinitionNameInjector
203
      = new DefinitionNameInjector( mDefinitionPane );
204
205
  public MainWindow() {
206
    mStatusBar = createStatusBar();
207
    mLineNumberText = createLineNumberText();
208
    mFindTextField = createFindTextField();
209
    mScene = createScene();
210
    mSpellChecker = createSpellChecker();
211
212
    // Add the close request listener before the window is shown.
213
    initLayout();
214
    StatusBarNotifier.setStatusBar( mStatusBar );
215
  }
216
217
  /**
218
   * Called after the stage is shown.
219
   */
220
  public void init() {
221
    initFindInput();
222
    initSnitch();
223
    initDefinitionListener();
224
    initTabAddedListener();
225
    initTabChangedListener();
226
    initPreferences();
227
    initVariableNameInjector();
228
  }
229
230
  private void initLayout() {
231
    final var scene = getScene();
232
233
    scene.getStylesheets().add( STYLESHEET_SCENE );
234
    scene.windowProperty().addListener(
235
        ( unused, oldWindow, newWindow ) ->
236
            newWindow.setOnCloseRequest(
237
                e -> {
238
                  if( !getFileEditorPane().closeAllEditors() ) {
239
                    e.consume();
240
                  }
241
                }
242
            )
243
    );
244
  }
245
246
  /**
247
   * Initialize the find input text field to listen on F3, ENTER, and
248
   * ESCAPE key presses.
249
   */
250
  private void initFindInput() {
251
    final TextField input = getFindTextField();
252
253
    input.setOnKeyPressed( ( KeyEvent event ) -> {
254
      switch( event.getCode() ) {
255
        case F3:
256
        case ENTER:
257
          editFindNext();
258
          break;
259
        case F:
260
          if( !event.isControlDown() ) {
261
            break;
262
          }
263
        case ESCAPE:
264
          getStatusBar().setGraphic( null );
265
          getActiveFileEditorTab().getEditorPane().requestFocus();
266
          break;
267
      }
268
    } );
269
270
    // Remove when the input field loses focus.
271
    input.focusedProperty().addListener(
272
        ( focused, oldFocus, newFocus ) -> {
273
          if( !newFocus ) {
274
            getStatusBar().setGraphic( null );
275
          }
276
        }
277
    );
278
  }
279
280
  /**
281
   * Watch for changes to external files. In particular, this awaits
282
   * modifications to any XSL files associated with XML files being edited.
283
   * When
284
   * an XSL file is modified (external to the application), the snitch's ears
285
   * perk up and the file is reloaded. This keeps the XSL transformation up to
286
   * date with what's on the file system.
287
   */
288
  private void initSnitch() {
289
    SNITCH.addObserver( this );
290
  }
291
292
  /**
293
   * Listen for {@link FileEditorTabPane} to receive open definition file
294
   * event.
295
   */
296
  private void initDefinitionListener() {
297
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
298
        ( final ObservableValue<? extends Path> file,
299
          final Path oldPath, final Path newPath ) -> {
300
          openDefinitions( newPath );
301
          rerender();
302
        }
303
    );
304
  }
305
306
  /**
307
   * Re-instantiates all processors then re-renders the active tab. This
308
   * will refresh the resolved map, force R to re-initialize, and brute-force
309
   * XSLT file reloads.
310
   */
311
  private void rerender() {
312
    runLater(
313
        () -> {
314
          resetProcessors();
315
          renderActiveTab();
316
        }
317
    );
318
  }
319
320
  /**
321
   * When tabs are added, hook the various change listeners onto the new
322
   * tab sothat the preview pane refreshes as necessary.
323
   */
324
  private void initTabAddedListener() {
325
    final FileEditorTabPane editorPane = getFileEditorPane();
326
327
    // Make sure the text processor kicks off when new files are opened.
328
    final ObservableList<Tab> tabs = editorPane.getTabs();
329
330
    // Update the preview pane on tab changes.
331
    tabs.addListener(
332
        ( final Change<? extends Tab> change ) -> {
333
          while( change.next() ) {
334
            if( change.wasAdded() ) {
335
              // Multiple tabs can be added simultaneously.
336
              for( final Tab newTab : change.getAddedSubList() ) {
337
                final FileEditorTab tab = (FileEditorTab) newTab;
338
339
                initTextChangeListener( tab );
340
                initScrollEventListener( tab );
341
                initSpellCheckListener( tab );
342
//              initSyntaxListener( tab );
343
              }
344
            }
345
          }
346
        }
347
    );
348
  }
349
350
  private void initTextChangeListener( final FileEditorTab tab ) {
351
    tab.addTextChangeListener(
352
        ( __, ov, nv ) -> {
353
          process( tab );
354
          scrollToParagraph( getCurrentParagraphIndex() );
355
        }
356
    );
357
  }
358
359
  private void initScrollEventListener( final FileEditorTab tab ) {
360
    final var scrollPane = tab.getScrollPane();
361
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
362
363
    addShowListener( scrollPane, ( __ ) -> {
364
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
365
      handler.enabledProperty().bind( tab.selectedProperty() );
366
    } );
367
  }
368
369
  /**
370
   * Listen for changes to the any particular paragraph and perform a quick
371
   * spell check upon it. The style classes in the editor will be changed to
372
   * mark any spelling mistakes in the paragraph. The user may then interact
373
   * with any misspelled word (i.e., any piece of text that is marked) to
374
   * revise the spelling.
375
   *
376
   * @param tab The tab to spellcheck.
377
   */
378
  private void initSpellCheckListener( final FileEditorTab tab ) {
379
    final var editor = tab.getEditorPane().getEditor();
380
381
    // When the editor first appears, run a full spell check. This allows
382
    // spell checking while typing to be restricted to the active paragraph,
383
    // which is usually substantially smaller than the whole document.
384
    addShowListener(
385
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
386
    );
387
388
    // Use the plain text changes so that notifications of style changes
389
    // are suppressed. Checking against the identity ensures that only
390
    // new text additions or deletions trigger proofreading.
391
    editor.plainTextChanges()
392
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
393
394
      // Only perform a spell check on the current paragraph. The
395
      // entire document is processed once, when opened.
396
      final var offset = change.getPosition();
397
      final var position = editor.offsetToPosition( offset, Forward );
398
      final var paraId = position.getMajor();
399
      final var paragraph = editor.getParagraph( paraId );
400
      final var text = paragraph.getText();
401
402
      // Ensure that styles aren't doubled-up.
403
      editor.clearStyle( paraId );
404
405
      spellcheck( editor, text, paraId );
406
    } );
407
  }
408
409
  /**
410
   * Listen for new tab selection events.
411
   */
412
  private void initTabChangedListener() {
413
    final FileEditorTabPane editorPane = getFileEditorPane();
414
415
    // Update the preview pane changing tabs.
416
    editorPane.addTabSelectionListener(
417
        ( tabPane, oldTab, newTab ) -> {
418
          if( newTab == null ) {
419
            // Clear the preview pane when closing an editor. When the last
420
            // tab is closed, this ensures that the preview pane is empty.
421
            getPreviewPane().clear();
422
          }
423
          else {
424
            final var tab = (FileEditorTab) newTab;
425
            updateVariableNameInjector( tab );
426
            process( tab );
427
          }
428
        }
429
    );
430
  }
431
432
  /**
433
   * Reloads the preferences from the previous session.
434
   */
435
  private void initPreferences() {
436
    initDefinitionPane();
437
    getFileEditorPane().initPreferences();
438
    getUserPreferences().addSaveEventHandler( mRPreferencesListener );
439
  }
440
441
  private void initVariableNameInjector() {
442
    updateVariableNameInjector( getActiveFileEditorTab() );
443
  }
444
445
  /**
446
   * Calls the listener when the given node is shown for the first time. The
447
   * visible property is not the same as the initial showing event; visibility
448
   * can be triggered numerous times (such as going off screen).
449
   * <p>
450
   * This is called, for example, before the drag handler can be attached,
451
   * because the scrollbar for the text editor pane must be visible.
452
   * </p>
453
   *
454
   * @param node     The node to watch for showing.
455
   * @param consumer The consumer to invoke when the event fires.
456
   */
457
  private void addShowListener(
458
      final Node node, final Consumer<Void> consumer ) {
459
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
460
        runLater( () -> {
461
          if( newShow != null && newShow ) {
462
            try {
463
              consumer.accept( null );
464
            } catch( final Exception ex ) {
465
              clue( ex );
466
            }
467
          }
468
        } );
469
470
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
471
       .flatMap( Window::showingProperty )
472
       .addListener( listener );
473
  }
474
475
  private void scrollToParagraph( final int id ) {
476
    scrollToParagraph( id, false );
477
  }
478
479
  /**
480
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
481
   *              exist.
482
   * @param force {@code true} means to force scrolling immediately, which
483
   *              should only be attempted when it is known that the document
484
   *              has been fully rendered. Otherwise the internal map of ID
485
   *              attributes will be incomplete and scrolling will flounder.
486
   */
487
  private void scrollToParagraph( final int id, final boolean force ) {
488
    synchronized( mMutex ) {
489
      final var previewPane = getPreviewPane();
490
      final var scrollPane = previewPane.getScrollPane();
491
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
492
493
      if( force ) {
494
        previewPane.scrollTo( approxId );
495
      }
496
      else {
497
        previewPane.tryScrollTo( approxId );
498
      }
499
500
      scrollPane.repaint();
501
    }
502
  }
503
504
  private void updateVariableNameInjector( final FileEditorTab tab ) {
505
    getDefinitionNameInjector().addListener( tab );
506
  }
507
508
  /**
509
   * Called whenever the preview pane becomes out of sync with the file editor
510
   * tab. This can be called when the text changes, the caret paragraph
511
   * changes, or the file tab changes.
512
   *
513
   * @param tab The file editor tab that has been changed in some fashion.
514
   */
515
  private void process( final FileEditorTab tab ) {
516
    if( tab != null ) {
517
      getPreviewPane().setPath( tab.getPath() );
518
519
      final Processor<String> processor = getProcessors().computeIfAbsent(
520
          tab, p -> createProcessors( tab )
521
      );
522
523
      try {
524
        processChain( processor, tab.getEditorText() );
525
      } catch( final Exception ex ) {
526
        clue( ex );
527
      }
528
    }
529
  }
530
531
  private void renderActiveTab() {
532
    process( getActiveFileEditorTab() );
533
  }
534
535
  /**
536
   * Called when a definition source is opened.
537
   *
538
   * @param path Path to the definition source that was opened.
539
   */
540
  private void openDefinitions( final Path path ) {
541
    try {
542
      final var ds = createDefinitionSource( path );
543
      setDefinitionSource( ds );
544
545
      final var prefs = getUserPreferences();
546
      prefs.definitionPathProperty().setValue( path.toFile() );
547
      prefs.save();
548
549
      final var tooltipPath = new Tooltip( path.toString() );
550
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
551
552
      final var pane = getDefinitionPane();
553
      pane.update( ds );
554
      pane.addTreeChangeHandler( mTreeHandler );
555
      pane.addKeyEventHandler( mDefinitionKeyHandler );
556
      pane.filenameProperty().setValue( path.getFileName().toString() );
557
      pane.setTooltip( tooltipPath );
558
559
      interpolateResolvedMap();
560
    } catch( final Exception ex ) {
561
      clue( ex );
562
    }
563
  }
564
565
  private void exportDefinitions( final Path path ) {
566
    try {
567
      final var pane = getDefinitionPane();
568
      final var root = pane.getTreeView().getRoot();
569
      final var problemChild = pane.isTreeWellFormed();
570
571
      if( problemChild == null ) {
572
        getDefinitionSource().getTreeAdapter().export( root, path );
573
      }
574
      else {
575
        clue( "yaml.error.tree.form", problemChild.getValue() );
576
      }
577
    } catch( final Exception ex ) {
578
      clue( ex );
579
    }
580
  }
581
582
  private void interpolateResolvedMap() {
583
    final var treeMap = getDefinitionPane().toMap();
584
    final var map = new HashMap<>( treeMap );
585
    MapInterpolator.interpolate( map );
586
587
    getResolvedMap().clear();
588
    getResolvedMap().putAll( map );
589
  }
590
591
  private void initDefinitionPane() {
592
    openDefinitions( getDefinitionPath() );
593
  }
594
595
  //---- File actions -------------------------------------------------------
596
597
  /**
598
   * Called when an {@link Observable} instance has changed. This is called
599
   * by both the {@link Snitch} service and the notify service. The @link
600
   * Snitch} service can be called for different file types, including
601
   * {@link DefinitionSource} instances.
602
   *
603
   * @param observable The observed instance.
604
   * @param value      The noteworthy item.
605
   */
606
  @Override
607
  public void update( final Observable observable, final Object value ) {
608
    if( value instanceof Path && observable instanceof Snitch ) {
609
      updateSelectedTab();
610
    }
611
  }
612
613
  /**
614
   * Called when a file has been modified.
615
   */
616
  private void updateSelectedTab() {
617
    rerender();
618
  }
619
620
  /**
621
   * After resetting the processors, they will refresh anew to be up-to-date
622
   * with the files (text and definition) currently loaded into the editor.
623
   */
624
  private void resetProcessors() {
625
    getProcessors().clear();
626
  }
627
628
  //---- File actions -------------------------------------------------------
629
630
  private void fileNew() {
631
    getFileEditorPane().newEditor();
632
  }
633
634
  private void fileOpen() {
635
    getFileEditorPane().openFileDialog();
636
  }
637
638
  private void fileClose() {
639
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
640
  }
641
642
  /**
643
   * TODO: Upon closing, first remove the tab change listeners. (There's no
644
   * need to re-render each tab when all are being closed.)
645
   */
646
  private void fileCloseAll() {
647
    getFileEditorPane().closeAllEditors();
648
  }
649
650
  private void fileSave() {
651
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
652
  }
653
654
  private void fileSaveAs() {
655
    final FileEditorTab editor = getActiveFileEditorTab();
656
    getFileEditorPane().saveEditorAs( editor );
657
    getProcessors().remove( editor );
658
659
    try {
660
      process( editor );
661
    } catch( final Exception ex ) {
662
      clue( ex );
663
    }
664
  }
665
666
  private void fileSaveAll() {
667
    getFileEditorPane().saveAllEditors();
668
  }
669
670
  /**
671
   * Exports the contents of the current tab according to the given
672
   * {@link ExportFormat}.
673
   *
674
   * @param format Configures the {@link MarkdownProcessor} when exporting.
675
   */
676
  private void fileExport( final ExportFormat format ) {
677
    final var tab = getActiveFileEditorTab();
678
    final var context = createProcessorContext( tab, format );
679
    final var chain = ProcessorFactory.createProcessors( context );
680
    final var doc = tab.getEditorText();
681
    final var export = processChain( chain, doc );
682
683
    final var filename = format.toExportFilename( tab.getPath().toFile() );
684
    final var dir = getPreferences().get( "lastDirectory", null );
685
    final var lastDir = new File( dir == null ? "." : dir );
686
687
    final FileChooser chooser = new FileChooser();
688
    chooser.setTitle( get( "Dialog.file.choose.export.title" ) );
689
    chooser.setInitialFileName( filename.getName() );
690
    chooser.setInitialDirectory( lastDir );
691
692
    final File file = chooser.showSaveDialog( getWindow() );
693
694
    if( file != null ) {
695
      try {
696
        writeString( file.toPath(), export, UTF_8 );
697
        final var m = get( "Main.status.export.success", file.toString() );
698
        clue( m );
699
      } catch( final IOException e ) {
700
        clue( e );
701
      }
702
    }
703
  }
704
705
  private void fileExit() {
706
    final Window window = getWindow();
707
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
708
  }
709
710
  //---- Edit actions -------------------------------------------------------
711
712
  /**
713
   * Used to find text in the active file editor window.
714
   */
715
  private void editFind() {
716
    final TextField input = getFindTextField();
717
    getStatusBar().setGraphic( input );
718
    input.requestFocus();
719
  }
720
721
  public void editFindNext() {
722
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
723
  }
724
725
  public void editPreferences() {
726
    getUserPreferences().show();
727
  }
728
729
  //---- Insert actions -----------------------------------------------------
730
731
  /**
732
   * Delegates to the active editor to handle wrapping the current text
733
   * selection with leading and trailing strings.
734
   *
735
   * @param leading  The string to put before the selection.
736
   * @param trailing The string to put after the selection.
737
   */
738
  private void insertMarkdown(
739
      final String leading, final String trailing ) {
740
    getActiveEditorPane().surroundSelection( leading, trailing );
741
  }
742
743
  private void insertMarkdown(
744
      final String leading, final String trailing, final String hint ) {
745
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
746
  }
747
748
  //---- Help actions -------------------------------------------------------
749
750
  private void helpAbout() {
751
    final Alert alert = new Alert( INFORMATION );
752
    alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
753
    alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
754
    alert.setContentText( get( "Dialog.about.content" ) );
755
    alert.setGraphic( new ImageView( ICON_DIALOG ) );
756
    alert.initOwner( getWindow() );
757
758
    alert.showAndWait();
759
  }
760
761
  //---- Member creators ----------------------------------------------------
762
763
  private SpellChecker createSpellChecker() {
764
    try {
765
      final Collection<String> lexicon = readLexicon( "en.txt" );
766
      return SymSpellSpeller.forLexicon( lexicon );
767
    } catch( final Exception ex ) {
768
      clue( ex );
769
      return new PermissiveSpeller();
770
    }
771
  }
772
773
  /**
774
   * Creates processors suited to parsing and rendering different file types.
775
   *
776
   * @param tab The tab that is subjected to processing.
777
   * @return A processor suited to the file type specified by the tab's path.
778
   */
779
  private Processor<String> createProcessors( final FileEditorTab tab ) {
780
    final var context = createProcessorContext( tab );
781
    return ProcessorFactory.createProcessors( context );
782
  }
783
784
  private ProcessorContext createProcessorContext(
785
      final FileEditorTab tab, final ExportFormat format ) {
786
    final var pane = getPreviewPane();
787
    final var map = getResolvedMap();
788
    final var path = tab.getPath();
789
    return new ProcessorContext( pane, map, path, format );
790
  }
791
792
  private ProcessorContext createProcessorContext( final FileEditorTab tab ) {
793
    return createProcessorContext( tab, NONE );
794
  }
795
796
  private DefinitionPane createDefinitionPane() {
797
    return new DefinitionPane();
798
  }
799
800
  private HTMLPreviewPane createHTMLPreviewPane() {
801
    return new HTMLPreviewPane();
802
  }
803
804
  private DefinitionSource createDefaultDefinitionSource() {
805
    return new YamlDefinitionSource( getDefinitionPath() );
806
  }
807
808
  private DefinitionSource createDefinitionSource( final Path path ) {
809
    try {
810
      return createDefinitionFactory().createDefinitionSource( path );
811
    } catch( final Exception ex ) {
812
      clue( ex );
813
      return createDefaultDefinitionSource();
814
    }
815
  }
816
817
  private TextField createFindTextField() {
818
    return new TextField();
819
  }
820
821
  private DefinitionFactory createDefinitionFactory() {
822
    return new DefinitionFactory();
823
  }
824
825
  private StatusBar createStatusBar() {
826
    return new StatusBar();
827
  }
828
829
  private Scene createScene() {
830
    final SplitPane splitPane = new SplitPane(
831
        getDefinitionPane(),
832
        getFileEditorPane(),
833
        getPreviewPane() );
834
835
    splitPane.setDividerPositions(
836
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
837
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
838
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
839
840
    getDefinitionPane().prefHeightProperty()
841
                       .bind( splitPane.heightProperty() );
842
843
    final BorderPane borderPane = new BorderPane();
844
    borderPane.setPrefSize( 1280, 800 );
845
    borderPane.setTop( createMenuBar() );
846
    borderPane.setBottom( getStatusBar() );
847
    borderPane.setCenter( splitPane );
848
849
    final VBox statusBar = new VBox();
850
    statusBar.setAlignment( Pos.BASELINE_CENTER );
851
    statusBar.getChildren().add( getLineNumberText() );
852
    getStatusBar().getRightItems().add( statusBar );
853
854
    // Force preview pane refresh on Windows.
855
    if( SystemUtils.IS_OS_WINDOWS ) {
856
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
857
          ( l, oValue, nValue ) -> runLater(
858
              () -> getPreviewPane().getScrollPane().repaint()
859
          )
860
      );
861
    }
862
863
    return new Scene( borderPane );
864
  }
865
866
  private Text createLineNumberText() {
867
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
868
  }
869
870
  private Node createMenuBar() {
871
    final BooleanBinding activeFileEditorIsNull =
872
        getFileEditorPane().activeFileEditorProperty().isNull();
873
874
    // File actions
875
    final Action fileNewAction = new ActionBuilder()
876
        .setText( "Main.menu.file.new" )
877
        .setAccelerator( "Shortcut+N" )
878
        .setIcon( FILE_ALT )
879
        .setAction( e -> fileNew() )
880
        .build();
881
    final Action fileOpenAction = new ActionBuilder()
882
        .setText( "Main.menu.file.open" )
883
        .setAccelerator( "Shortcut+O" )
884
        .setIcon( FOLDER_OPEN_ALT )
885
        .setAction( e -> fileOpen() )
886
        .build();
887
    final Action fileCloseAction = new ActionBuilder()
888
        .setText( "Main.menu.file.close" )
889
        .setAccelerator( "Shortcut+W" )
890
        .setAction( e -> fileClose() )
891
        .setDisable( activeFileEditorIsNull )
892
        .build();
893
    final Action fileCloseAllAction = new ActionBuilder()
894
        .setText( "Main.menu.file.close_all" )
895
        .setAction( e -> fileCloseAll() )
896
        .setDisable( activeFileEditorIsNull )
897
        .build();
898
    final Action fileSaveAction = new ActionBuilder()
899
        .setText( "Main.menu.file.save" )
900
        .setAccelerator( "Shortcut+S" )
901
        .setIcon( FLOPPY_ALT )
902
        .setAction( e -> fileSave() )
903
        .setDisable( createActiveBooleanProperty(
904
            FileEditorTab::modifiedProperty ).not() )
905
        .build();
906
    final Action fileSaveAsAction = new ActionBuilder()
907
        .setText( "Main.menu.file.save_as" )
908
        .setAction( e -> fileSaveAs() )
909
        .setDisable( activeFileEditorIsNull )
910
        .build();
911
    final Action fileSaveAllAction = new ActionBuilder()
912
        .setText( "Main.menu.file.save_all" )
913
        .setAccelerator( "Shortcut+Shift+S" )
914
        .setAction( e -> fileSaveAll() )
915
        .setDisable( Bindings.not(
916
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
917
        .build();
918
    final Action fileExportAction = new ActionBuilder()
919
        .setText( "Main.menu.file.export" )
920
        .build();
921
    final Action fileExportHtmlSvgAction = new ActionBuilder()
922
        .setText( "Main.menu.file.export.html_svg" )
923
        .setAction( e -> fileExport( HTML_TEX_SVG ) )
924
        .build();
925
    final Action fileExportHtmlTexAction = new ActionBuilder()
926
        .setText( "Main.menu.file.export.html_tex" )
927
        .setAction( e -> fileExport( HTML_TEX_DELIMITED ) )
928
        .build();
929
    final Action fileExportMarkdownAction = new ActionBuilder()
930
        .setText( "Main.menu.file.export.markdown" )
931
        .setAction( e -> fileExport( MARKDOWN_PLAIN ) )
932
        .build();
933
    fileExportAction.addSubActions(
934
        fileExportHtmlSvgAction,
935
        fileExportHtmlTexAction,
936
        fileExportMarkdownAction );
937
938
    final Action fileExitAction = new ActionBuilder()
939
        .setText( "Main.menu.file.exit" )
940
        .setAction( e -> fileExit() )
941
        .build();
942
943
    // Edit actions
944
    final Action editUndoAction = new ActionBuilder()
945
        .setText( "Main.menu.edit.undo" )
946
        .setAccelerator( "Shortcut+Z" )
947
        .setIcon( UNDO )
948
        .setAction( e -> getActiveEditorPane().undo() )
949
        .setDisable( createActiveBooleanProperty(
950
            FileEditorTab::canUndoProperty ).not() )
951
        .build();
952
    final Action editRedoAction = new ActionBuilder()
953
        .setText( "Main.menu.edit.redo" )
954
        .setAccelerator( "Shortcut+Y" )
955
        .setIcon( REPEAT )
956
        .setAction( e -> getActiveEditorPane().redo() )
957
        .setDisable( createActiveBooleanProperty(
958
            FileEditorTab::canRedoProperty ).not() )
959
        .build();
960
961
    final Action editCutAction = new ActionBuilder()
962
        .setText( "Main.menu.edit.cut" )
963
        .setAccelerator( "Shortcut+X" )
964
        .setIcon( CUT )
965
        .setAction( e -> getActiveEditorPane().cut() )
966
        .setDisable( activeFileEditorIsNull )
967
        .build();
968
    final Action editCopyAction = new ActionBuilder()
969
        .setText( "Main.menu.edit.copy" )
970
        .setAccelerator( "Shortcut+C" )
971
        .setIcon( COPY )
972
        .setAction( e -> getActiveEditorPane().copy() )
973
        .setDisable( activeFileEditorIsNull )
974
        .build();
975
    final Action editPasteAction = new ActionBuilder()
976
        .setText( "Main.menu.edit.paste" )
977
        .setAccelerator( "Shortcut+V" )
978
        .setIcon( PASTE )
979
        .setAction( e -> getActiveEditorPane().paste() )
980
        .setDisable( activeFileEditorIsNull )
981
        .build();
982
    final Action editSelectAllAction = new ActionBuilder()
983
        .setText( "Main.menu.edit.selectAll" )
984
        .setAccelerator( "Shortcut+A" )
985
        .setAction( e -> getActiveEditorPane().selectAll() )
986
        .setDisable( activeFileEditorIsNull )
987
        .build();
988
989
    final Action editFindAction = new ActionBuilder()
990
        .setText( "Main.menu.edit.find" )
991
        .setAccelerator( "Ctrl+F" )
992
        .setIcon( SEARCH )
993
        .setAction( e -> editFind() )
994
        .setDisable( activeFileEditorIsNull )
995
        .build();
996
    final Action editFindNextAction = new ActionBuilder()
997
        .setText( "Main.menu.edit.find.next" )
998
        .setAccelerator( "F3" )
999
        .setAction( e -> editFindNext() )
1000
        .setDisable( activeFileEditorIsNull )
1001
        .build();
1002
    final Action editPreferencesAction = new ActionBuilder()
1003
        .setText( "Main.menu.edit.preferences" )
1004
        .setAccelerator( "Ctrl+Alt+S" )
1005
        .setAction( e -> editPreferences() )
1006
        .build();
1007
1008
    // Format actions
1009
    final Action formatBoldAction = new ActionBuilder()
1010
        .setText( "Main.menu.format.bold" )
1011
        .setAccelerator( "Shortcut+B" )
1012
        .setIcon( BOLD )
1013
        .setAction( e -> insertMarkdown( "**", "**" ) )
1014
        .setDisable( activeFileEditorIsNull )
1015
        .build();
1016
    final Action formatItalicAction = new ActionBuilder()
1017
        .setText( "Main.menu.format.italic" )
1018
        .setAccelerator( "Shortcut+I" )
1019
        .setIcon( ITALIC )
1020
        .setAction( e -> insertMarkdown( "*", "*" ) )
1021
        .setDisable( activeFileEditorIsNull )
1022
        .build();
1023
    final Action formatSuperscriptAction = new ActionBuilder()
1024
        .setText( "Main.menu.format.superscript" )
1025
        .setAccelerator( "Shortcut+[" )
1026
        .setIcon( SUPERSCRIPT )
1027
        .setAction( e -> insertMarkdown( "^", "^" ) )
1028
        .setDisable( activeFileEditorIsNull )
1029
        .build();
1030
    final Action formatSubscriptAction = new ActionBuilder()
1031
        .setText( "Main.menu.format.subscript" )
1032
        .setAccelerator( "Shortcut+]" )
1033
        .setIcon( SUBSCRIPT )
1034
        .setAction( e -> insertMarkdown( "~", "~" ) )
1035
        .setDisable( activeFileEditorIsNull )
1036
        .build();
1037
    final Action formatStrikethroughAction = new ActionBuilder()
1038
        .setText( "Main.menu.format.strikethrough" )
1039
        .setAccelerator( "Shortcut+T" )
1040
        .setIcon( STRIKETHROUGH )
1041
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1042
        .setDisable( activeFileEditorIsNull )
1043
        .build();
1044
1045
    // Insert actions
1046
    final Action insertBlockquoteAction = new ActionBuilder()
1047
        .setText( "Main.menu.insert.blockquote" )
1048
        .setAccelerator( "Ctrl+Q" )
1049
        .setIcon( QUOTE_LEFT )
1050
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1051
        .setDisable( activeFileEditorIsNull )
1052
        .build();
1053
    final Action insertCodeAction = new ActionBuilder()
1054
        .setText( "Main.menu.insert.code" )
1055
        .setAccelerator( "Shortcut+K" )
1056
        .setIcon( CODE )
1057
        .setAction( e -> insertMarkdown( "`", "`" ) )
1058
        .setDisable( activeFileEditorIsNull )
1059
        .build();
1060
    final Action insertFencedCodeBlockAction = new ActionBuilder()
1061
        .setText( "Main.menu.insert.fenced_code_block" )
1062
        .setAccelerator( "Shortcut+Shift+K" )
1063
        .setIcon( FILE_CODE_ALT )
1064
        .setAction( e -> insertMarkdown(
1065
            "\n\n```\n",
1066
            "\n```\n\n",
1067
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1068
        .setDisable( activeFileEditorIsNull )
1069
        .build();
1070
    final Action insertLinkAction = new ActionBuilder()
1071
        .setText( "Main.menu.insert.link" )
1072
        .setAccelerator( "Shortcut+L" )
1073
        .setIcon( LINK )
1074
        .setAction( e -> getActiveEditorPane().insertLink() )
1075
        .setDisable( activeFileEditorIsNull )
1076
        .build();
1077
    final Action insertImageAction = new ActionBuilder()
1078
        .setText( "Main.menu.insert.image" )
1079
        .setAccelerator( "Shortcut+G" )
1080
        .setIcon( PICTURE_ALT )
1081
        .setAction( e -> getActiveEditorPane().insertImage() )
1082
        .setDisable( activeFileEditorIsNull )
1083
        .build();
1084
1085
    // Number of heading actions (H1 ... H3)
1086
    final int HEADINGS = 3;
1087
    final Action[] headings = new Action[ HEADINGS ];
1088
1089
    for( int i = 1; i <= HEADINGS; i++ ) {
1090
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1091
      final String markup = String.format( "%n%n%s ", hashes );
1092
      final String text = "Main.menu.insert.heading." + i;
1093
      final String accelerator = "Shortcut+" + i;
1094
      final String prompt = text + ".prompt";
1095
1096
      headings[ i - 1 ] = new ActionBuilder()
1097
          .setText( text )
1098
          .setAccelerator( accelerator )
1099
          .setIcon( HEADER )
1100
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1101
          .setDisable( activeFileEditorIsNull )
1102
          .build();
1103
    }
1104
1105
    final Action insertUnorderedListAction = new ActionBuilder()
1106
        .setText( "Main.menu.insert.unordered_list" )
1107
        .setAccelerator( "Shortcut+U" )
1108
        .setIcon( LIST_UL )
1109
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1110
        .setDisable( activeFileEditorIsNull )
1111
        .build();
1112
    final Action insertOrderedListAction = new ActionBuilder()
1113
        .setText( "Main.menu.insert.ordered_list" )
1114
        .setAccelerator( "Shortcut+Shift+O" )
1115
        .setIcon( LIST_OL )
1116
        .setAction( e -> insertMarkdown(
1117
            "\n\n1. ", "" ) )
1118
        .setDisable( activeFileEditorIsNull )
1119
        .build();
1120
    final Action insertHorizontalRuleAction = new ActionBuilder()
1121
        .setText( "Main.menu.insert.horizontal_rule" )
1122
        .setAccelerator( "Shortcut+H" )
1123
        .setAction( e -> insertMarkdown(
1124
            "\n\n---\n\n", "" ) )
1125
        .setDisable( activeFileEditorIsNull )
1126
        .build();
1127
1128
    // Definition actions
1129
    final Action definitionCreateAction = new ActionBuilder()
1130
        .setText( "Main.menu.definition.create" )
1131
        .setIcon( TREE )
1132
        .setAction( e -> getDefinitionPane().addItem() )
1133
        .build();
1134
    final Action definitionInsertAction = new ActionBuilder()
1135
        .setText( "Main.menu.definition.insert" )
1136
        .setAccelerator( "Ctrl+Space" )
1137
        .setIcon( STAR )
1138
        .setAction( e -> definitionInsert() )
1139
        .build();
1140
1141
    // Help actions
1142
    final Action helpAboutAction = new ActionBuilder()
1143
        .setText( "Main.menu.help.about" )
1144
        .setAction( e -> helpAbout() )
1145
        .build();
1146
1147
    final Action SEPARATOR_ACTION = new SeparatorAction();
1148
1149
    //---- MenuBar ----
1150
1151
    // File Menu
1152
    final var fileMenu = ActionUtils.createMenu(
1153
        get( "Main.menu.file" ),
1154
        fileNewAction,
1155
        fileOpenAction,
1156
        SEPARATOR_ACTION,
1157
        fileCloseAction,
1158
        fileCloseAllAction,
1159
        SEPARATOR_ACTION,
1160
        fileSaveAction,
1161
        fileSaveAsAction,
1162
        fileSaveAllAction,
1163
        SEPARATOR_ACTION,
1164
        fileExportAction,
1165
        SEPARATOR_ACTION,
1166
        fileExitAction );
1167
1168
    // Edit Menu
1169
    final var editMenu = ActionUtils.createMenu(
1170
        get( "Main.menu.edit" ),
1171
        SEPARATOR_ACTION,
1172
        editUndoAction,
1173
        editRedoAction,
1174
        SEPARATOR_ACTION,
1175
        editCutAction,
1176
        editCopyAction,
1177
        editPasteAction,
1178
        editSelectAllAction,
1179
        SEPARATOR_ACTION,
1180
        editFindAction,
1181
        editFindNextAction,
1182
        SEPARATOR_ACTION,
1183
        editPreferencesAction );
1184
1185
    // Format Menu
1186
    final var formatMenu = ActionUtils.createMenu(
1187
        get( "Main.menu.format" ),
1188
        formatBoldAction,
1189
        formatItalicAction,
1190
        formatSuperscriptAction,
1191
        formatSubscriptAction,
1192
        formatStrikethroughAction
1193
    );
1194
1195
    // Insert Menu
1196
    final var insertMenu = ActionUtils.createMenu(
1197
        get( "Main.menu.insert" ),
1198
        insertBlockquoteAction,
1199
        insertCodeAction,
1200
        insertFencedCodeBlockAction,
1201
        SEPARATOR_ACTION,
1202
        insertLinkAction,
1203
        insertImageAction,
1204
        SEPARATOR_ACTION,
1205
        headings[ 0 ],
1206
        headings[ 1 ],
1207
        headings[ 2 ],
1208
        SEPARATOR_ACTION,
1209
        insertUnorderedListAction,
1210
        insertOrderedListAction,
1211
        insertHorizontalRuleAction
1212
    );
1213
1214
    // Definition Menu
1215
    final var definitionMenu = ActionUtils.createMenu(
1216
        get( "Main.menu.definition" ),
1217
        definitionCreateAction,
1218
        definitionInsertAction );
1219
1220
    // Help Menu
1221
    final var helpMenu = ActionUtils.createMenu(
1222
        get( "Main.menu.help" ),
1223
        helpAboutAction );
1224
1225
    //---- MenuBar ----
1226
    final var menuBar = new MenuBar(
1227
        fileMenu,
1228
        editMenu,
1229
        formatMenu,
1230
        insertMenu,
1231
        definitionMenu,
1232
        helpMenu );
1233
1234
    //---- ToolBar ----
1235
    final var toolBar = ActionUtils.createToolBar(
1236
        fileNewAction,
1237
        fileOpenAction,
1238
        fileSaveAction,
1239
        SEPARATOR_ACTION,
1240
        editUndoAction,
1241
        editRedoAction,
1242
        editCutAction,
1243
        editCopyAction,
1244
        editPasteAction,
1245
        SEPARATOR_ACTION,
1246
        formatBoldAction,
1247
        formatItalicAction,
1248
        formatSuperscriptAction,
1249
        formatSubscriptAction,
1250
        insertBlockquoteAction,
1251
        insertCodeAction,
1252
        insertFencedCodeBlockAction,
1253
        SEPARATOR_ACTION,
1254
        insertLinkAction,
1255
        insertImageAction,
1256
        SEPARATOR_ACTION,
1257
        headings[ 0 ],
1258
        SEPARATOR_ACTION,
12361259
        insertUnorderedListAction,
12371260
        insertOrderedListAction );
M src/main/java/com/keenwrite/StatusBarNotifier.java
5353
   * Resets the status bar to a default message.
5454
   */
55
  public static void clearAlert() {
55
  public static void clearClue() {
5656
    // Don't burden the repaint thread if there's no status bar change.
5757
    if( !OK.equals( sStatusBar.getText() ) ) {
...
6666
   *            to inform the user about an error).
6767
   */
68
  public static void alert( final String key ) {
68
  public static void clue( final String key ) {
6969
    update( get( key ) );
7070
  }
7171
7272
  /**
7373
   * Updates the status bar with a custom message.
7474
   *
7575
   * @param key  The property key having a value to populate with arguments.
7676
   * @param args The placeholder values to substitute into the key's value.
7777
   */
78
  public static void alert( final String key, final Object... args ) {
78
  public static void clue( final String key, final Object... args ) {
7979
    update( get( key, args ) );
8080
  }
8181
8282
  /**
8383
   * Called when an exception occurs that warrants the user's attention.
8484
   *
8585
   * @param t The exception with a message that the user should know about.
8686
   */
87
  public static void alert( final Throwable t ) {
87
  public static void clue( final Throwable t ) {
8888
    update( t.getMessage() );
8989
  }
M src/main/java/com/keenwrite/adapters/DocumentAdapter.java
3030
import org.xhtmlrenderer.event.DocumentListener;
3131
32
import static com.keenwrite.StatusBarNotifier.alert;
32
import static com.keenwrite.StatusBarNotifier.clue;
3333
3434
/**
...
4646
  @Override
4747
  public void onLayoutException( final Throwable t ) {
48
    alert( t );
48
    clue( t );
4949
  }
5050
5151
  @Override
5252
  public void onRenderException( final Throwable t ) {
53
    alert( t );
53
    clue( t );
5454
  }
5555
}
M src/main/java/com/keenwrite/definition/DefinitionFactory.java
3131
import com.keenwrite.FileType;
3232
import com.keenwrite.definition.yaml.YamlDefinitionSource;
33
import com.keenwrite.util.ProtocolScheme;
3334
3435
import java.nio.file.Path;
3536
3637
import static com.keenwrite.Constants.GLOB_PREFIX_DEFINITION;
3738
import static com.keenwrite.FileType.YAML;
3839
import static com.keenwrite.util.ProtocolResolver.getProtocol;
40
import static java.lang.String.format;
3941
4042
/**
4143
 * Responsible for creating objects that can read and write definition data
4244
 * sources. The data source could be YAML, TOML, JSON, flat files, or from a
4345
 * database.
4446
 */
4547
public class DefinitionFactory extends AbstractFileFactory {
48
49
  /**
50
   * TODO: Use an error message key from messages properties file.
51
   */
52
  private static final String MSG_UNKNOWN_FILE_TYPE =
53
      "Unknown type '%s' for file '%s'.";
4654
4755
  /**
...
92100
93101
    throw new IllegalArgumentException( filetype.toString() );
102
  }
103
104
  /**
105
   * Throws IllegalArgumentException because the given path could not be
106
   * recognized. This exists because
107
   *
108
   * @param type The detected path type (protocol, file extension, etc.).
109
   * @param path The path to a source of definitions.
110
   */
111
  private void unknownFileType(
112
      final ProtocolScheme type, final String path ) {
113
    final String msg = format( MSG_UNKNOWN_FILE_TYPE, type, path );
114
    throw new IllegalArgumentException( msg );
94115
  }
95116
}
M src/main/java/com/keenwrite/editors/DefinitionDecoratorFactory.java
4141
public class DefinitionDecoratorFactory extends AbstractFileFactory {
4242
43
  /**
44
   * Prevent instantiation.
45
   */
4346
  private DefinitionDecoratorFactory() {
4447
  }
4548
4649
  public static SigilOperator newInstance( final Path path ) {
47
    final var factory = new DefinitionDecoratorFactory();
48
49
    return switch( factory.lookup( path ) ) {
50
    return switch( lookup( path ) ) {
5051
      case RMARKDOWN, RXML -> new RSigilOperator();
5152
      default -> new YamlSigilOperator();
M src/main/java/com/keenwrite/editors/DefinitionNameInjector.java
4040
4141
import static com.keenwrite.Constants.*;
42
import static com.keenwrite.StatusBarNotifier.alert;
42
import static com.keenwrite.StatusBarNotifier.clue;
4343
import static java.lang.Character.isWhitespace;
4444
import static javafx.scene.input.KeyCode.SPACE;
...
110110
    try {
111111
      if( isEmptyDefinitionPane() ) {
112
        alert( STATUS_DEFINITION_EMPTY );
112
        clue( STATUS_DEFINITION_EMPTY );
113113
      }
114114
      else {
115115
        final String word = paragraph.substring( bounds[ 0 ], bounds[ 1 ] );
116116
117117
        if( word.isBlank() ) {
118
          alert( STATUS_DEFINITION_BLANK );
118
          clue( STATUS_DEFINITION_BLANK );
119119
        }
120120
        else {
121121
          final var leaf = findLeaf( word );
122122
123123
          if( leaf == null ) {
124
            alert( STATUS_DEFINITION_MISSING, word );
124
            clue( STATUS_DEFINITION_MISSING, word );
125125
          }
126126
          else {
127127
            replaceText( bounds[ 0 ], bounds[ 1 ], decorate( leaf ) );
128128
            expand( leaf );
129129
          }
130130
        }
131131
      }
132132
    } catch( final Exception ignored ) {
133
      alert( STATUS_DEFINITION_BLANK );
133
      clue( STATUS_DEFINITION_BLANK );
134134
    }
135135
  }
M src/main/java/com/keenwrite/editors/EditorPane.java
4545
import java.util.function.Consumer;
4646
47
import static com.keenwrite.StatusBarNotifier.clearAlert;
47
import static com.keenwrite.StatusBarNotifier.clearClue;
4848
import static java.lang.String.format;
4949
import static javafx.application.Platform.runLater;
...
7676
    // was no previous error, clearing the alert is essentially a no-op.
7777
    mEditor.textProperty().addListener(
78
        ( l, o, n ) -> clearAlert()
78
        ( l, o, n ) -> clearClue()
7979
    );
8080
  }
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditorPane.java
319319
320320
    // Get the current paragraph, convert to Markdown nodes.
321
    final MarkdownProcessor mp = new MarkdownProcessor( null );
321
    final MarkdownProcessor mp = MarkdownProcessor.create();
322322
    final int p = textArea.getCurrentParagraph();
323323
    final String paragraph = textArea.getText( p );
M src/main/java/com/keenwrite/preferences/FilePreferences.java
3535
import java.util.prefs.BackingStoreException;
3636
37
import static com.keenwrite.StatusBarNotifier.alert;
37
import static com.keenwrite.StatusBarNotifier.clue;
3838
3939
/**
...
6060
      sync();
6161
    } catch( final BackingStoreException ex ) {
62
      alert( ex );
62
      clue( ex );
6363
    }
6464
  }
...
7373
      flush();
7474
    } catch( final BackingStoreException ex ) {
75
      alert( ex );
75
      clue( ex );
7676
    }
7777
  }
...
9393
      flush();
9494
    } catch( final BackingStoreException ex ) {
95
      alert( ex );
95
      clue( ex );
9696
    }
9797
  }
...
161161
        }
162162
      } catch( final Exception ex ) {
163
        alert( ex );
163
        clue( ex );
164164
      }
165165
    }
...
221221
        }
222222
      } catch( final Exception ex ) {
223
        alert( ex );
223
        clue( ex );
224224
      }
225225
    }
M src/main/java/com/keenwrite/preview/CustomImageLoader.java
4040
import java.nio.file.Paths;
4141
42
import static com.keenwrite.StatusBarNotifier.alert;
42
import static com.keenwrite.StatusBarNotifier.clue;
4343
import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER;
4444
import static com.keenwrite.util.ProtocolResolver.getProtocol;
...
114114
      return scale( imageResource );
115115
    } catch( final Exception e ) {
116
      alert( e );
116
      clue( e );
117117
      return new ImageResource( uri, getBrokenImage() );
118118
    }
M src/main/java/com/keenwrite/preview/HTMLPreviewPane.java
3737
import org.jsoup.Jsoup;
3838
import org.jsoup.helper.W3CDom;
39
import org.jsoup.nodes.Document;
4039
import org.xhtmlrenderer.layout.SharedContext;
4140
import org.xhtmlrenderer.render.Box;
...
5251
5352
import static com.keenwrite.Constants.*;
54
import static com.keenwrite.StatusBarNotifier.alert;
53
import static com.keenwrite.StatusBarNotifier.clue;
5554
import static com.keenwrite.util.ProtocolResolver.getProtocol;
5655
import static java.awt.Desktop.Action.BROWSE;
...
146145
        }
147146
      } catch( final Exception ex ) {
148
        alert( ex );
147
        clue( ex );
149148
      }
150149
    }
151150
  }
152151
153152
  /**
154
   * The CSS must be rendered in points (pt) not pixels (px) to avoid blurry
155
   * rendering on some platforms.
153
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
154
   * poor rendering.
156155
   */
157156
  private static final String HTML_PREFIX = "<!DOCTYPE html>"
158157
      + "<html>"
159158
      + "<head>"
160159
      + "<link rel='stylesheet' href='" +
161160
      HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW ) + "'/>"
162161
      + "</head>"
163162
      + "<body>";
163
164
  /**
165
   * Used to reset the {@link #mHtmlDocument} buffer so that the
166
   * {@link #HTML_PREFIX} need not be appended all the time.
167
   */
168
  private static final int HTML_PREFIX_LENGTH = HTML_PREFIX.length();
164169
165170
  private static final W3CDom W3C_DOM = new W3CDom();
166171
  private static final XhtmlNamespaceHandler NS_HANDLER =
167172
      new XhtmlNamespaceHandler();
168173
174
  /**
175
   * The buffer is reused so that previous memory allocations need not repeat.
176
   */
169177
  private final StringBuilder mHtmlDocument = new StringBuilder( 65536 );
170
  private final int mHtmlPrefixLength;
171178
172179
  private final HTMLPanel mHtmlRenderer = new HTMLPanel();
...
186193
    // No need to append same prefix each time the HTML content is updated.
187194
    mHtmlDocument.append( HTML_PREFIX );
188
    mHtmlPrefixLength = mHtmlDocument.length();
189195
190196
    // Inject an SVG renderer that produces high-quality SVG buffered images.
...
222228
   */
223229
  public void process( final String html ) {
224
    final Document jsoupDoc = Jsoup.parse( decorate( html ) );
225
    final org.w3c.dom.Document w3cDoc = W3C_DOM.fromJsoup( jsoupDoc );
226
230
    final var docJsoup = Jsoup.parse( decorate( html ) );
231
    final var docW3c = W3C_DOM.fromJsoup( docJsoup );
227232
228233
    // Access to a Swing component must occur from the Event Dispatch
229
    // thread according to Swing threading restrictions.
234
    // Thread (EDT) according to Swing threading restrictions.
230235
    invokeLater(
231
        () -> mHtmlRenderer.setDocument( w3cDoc, getBaseUrl(), NS_HANDLER )
236
        () -> mHtmlRenderer.setDocument( docW3c, getBaseUrl(), NS_HANDLER )
232237
    );
233238
  }
234239
240
  /**
241
   * Clears the preview pane by rendering an empty string.
242
   */
235243
  public void clear() {
236244
    process( "" );
...
334342
  private String decorate( final String html ) {
335343
    // Trim the HTML back to only the prefix.
336
    mHtmlDocument.setLength( mHtmlPrefixLength );
344
    mHtmlDocument.setLength( HTML_PREFIX_LENGTH );
337345
338346
    // Write the HTML body element followed by closing tags.
M src/main/java/com/keenwrite/preview/MathRenderer.java
3434
import java.util.function.Supplier;
3535
36
import static com.keenwrite.StatusBarNotifier.alert;
36
import static com.keenwrite.StatusBarNotifier.clue;
3737
3838
/**
...
9595
      return supplier.get();
9696
    } catch( final Exception ex ) {
97
      alert( ex );
97
      clue( ex );
9898
      return null;
9999
    }
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
5050
import java.text.NumberFormat;
5151
52
import static com.keenwrite.StatusBarNotifier.alert;
52
import static com.keenwrite.StatusBarNotifier.clue;
5353
import static com.keenwrite.preview.RenderingSettings.RENDERING_HINTS;
5454
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
...
198198
      return rasterize( new URL( url ), width );
199199
    } catch( final Exception ex ) {
200
      alert( ex );
200
      clue( ex );
201201
      return BROKEN_IMAGE_PLACEHOLDER;
202202
    }
...
245245
      return rasterize( document, INT_FORMAT.parse( width ).intValue() );
246246
    } catch( final Exception ex ) {
247
      alert( ex );
247
      clue( ex );
248248
      return BROKEN_IMAGE_PLACEHOLDER;
249249
    }
...
280280
      return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() );
281281
    } catch( final Exception ex ) {
282
      alert( ex );
282
      clue( ex );
283283
      return BROKEN_IMAGE_PLACEHOLDER;
284284
    }
...
311311
      return writer.toString().replaceAll( "xmlns=\"\" ", "" );
312312
    } catch( final Exception ex ) {
313
      alert( ex );
313
      clue( ex );
314314
    }
315315
M src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
4343
import java.util.function.Function;
4444
45
import static com.keenwrite.StatusBarNotifier.alert;
45
import static com.keenwrite.StatusBarNotifier.clue;
4646
import static com.keenwrite.preview.SvgRasterizer.rasterize;
47
import static com.keenwrite.processors.markdown.tex.TeXNode.HTML_TEX;
47
import static com.keenwrite.processors.markdown.tex.TexNode.HTML_TEX;
4848
4949
/**
...
116116
        }
117117
      } catch( final Exception ex ) {
118
        alert( ex );
118
        clue( ex );
119119
      }
120120
    }
M src/main/java/com/keenwrite/processors/IdentityProcessor.java
2929
3030
/**
31
 * This is the default processor used when an unknown filename extension is
32
 * encountered.
31
 * Responsible for transforming a string into itself. This is typically used
32
 * at the end of a processing chain when no more processing is required, such
33
 * as when exporting files.
3334
 */
3435
public class IdentityProcessor extends AbstractProcessor<String> {
...
4546
4647
  /**
47
   * Returns the given string, modified with "pre" tags.
48
   * Returns the given string without modification.
4849
   *
49
   * @param t The string to return, enclosed in "pre" tags.
50
   * @return The value of t wrapped in "pre" tags.
50
   * @param s The string to return.
51
   * @return The value of s.
5152
   */
5253
  @Override
53
  public String apply( final String t ) {
54
    return "<pre>" + t + "</pre>";
54
  public String apply( final String s ) {
55
    return s;
5556
  }
5657
}
M src/main/java/com/keenwrite/processors/InlineRProcessor.java
2828
package com.keenwrite.processors;
2929
30
import com.keenwrite.StatusBarNotifier;
3031
import com.keenwrite.preferences.UserPreferences;
3132
import javafx.beans.property.ObjectProperty;
...
4142
4243
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
43
import static com.keenwrite.StatusBarNotifier.alert;
44
import static com.keenwrite.StatusBarNotifier.clue;
4445
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
4546
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
...
188189
189190
          // Tell the user that there was a problem.
190
          alert( STATUS_PARSE_ERROR, e.getMessage(), currIndex );
191
          StatusBarNotifier.clue( STATUS_PARSE_ERROR, e.getMessage(), currIndex );
191192
        }
192193
...
225226
    } catch( final Exception ex ) {
226227
      final String expr = r.substring( 0, min( r.length(), 30 ) );
227
      alert( "Main.status.error.r", expr, ex.getMessage() );
228
      StatusBarNotifier.clue( "Main.status.error.r", expr, ex.getMessage() );
228229
    }
229230
A src/main/java/com/keenwrite/processors/PreformattedProcessor.java
1
/*
2
 * Copyright 2017 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.processors;
29
30
/**
31
 * This is the default processor used when an unknown filename extension is
32
 * encountered. It processes the text by enclosing it in an HTML {@code <pre>}
33
 * element.
34
 */
35
public class PreformattedProcessor extends AbstractProcessor<String> {
36
37
  /**
38
   * Passes the link to the super constructor.
39
   *
40
   * @param successor The next processor in the chain to use for text
41
   *                  processing.
42
   */
43
  public PreformattedProcessor( final Processor<String> successor ) {
44
    super( successor );
45
  }
46
47
  /**
48
   * Returns the given string, modified with "pre" tags.
49
   *
50
   * @param t The string to return, enclosed in "pre" tags.
51
   * @return The value of t wrapped in "pre" tags.
52
   */
53
  @Override
54
  public String apply( final String t ) {
55
    return "<pre>" + t + "</pre>";
56
  }
57
}
158
A src/main/java/com/keenwrite/processors/ProcessorContext.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.processors;
29
30
import com.keenwrite.ExportFormat;
31
import com.keenwrite.FileType;
32
import com.keenwrite.preview.HTMLPreviewPane;
33
34
import java.nio.file.Path;
35
import java.util.Map;
36
37
import static com.keenwrite.AbstractFileFactory.lookup;
38
39
/**
40
 * Provides a context for configuring a chain of {@link Processor} instances.
41
 */
42
public class ProcessorContext {
43
  private final HTMLPreviewPane mPreviewPane;
44
  private final Map<String, String> mResolvedMap;
45
  private final ExportFormat mExportFormat;
46
  private final FileType mFileType;
47
  private final Path mPath;
48
49
50
  /**
51
   * Creates a new context for use by the {@link ProcessorFactory} when
52
   * instantiating new {@link Processor} instances. Although all the
53
   * parameters are required, not all {@link Processor} instances will use
54
   * all parameters.
55
   *
56
   * @param previewPane Where to display the final (HTML) output.
57
   * @param resolvedMap Fully expanded interpolated strings.
58
   * @param path        Path to the document to process.
59
   * @param format      Indicate configuration options for export format.
60
   */
61
  public ProcessorContext(
62
      final HTMLPreviewPane previewPane,
63
      final Map<String, String> resolvedMap,
64
      final Path path,
65
      final ExportFormat format ) {
66
    mPreviewPane = previewPane;
67
    mResolvedMap = resolvedMap;
68
    mPath = path;
69
    mFileType = lookup( path );
70
    mExportFormat = format;
71
  }
72
73
  HTMLPreviewPane getPreviewPane() {
74
    return mPreviewPane;
75
  }
76
77
  Map<String, String> getResolvedMap() {
78
    return mResolvedMap;
79
  }
80
81
  public Path getPath() {
82
    return mPath;
83
  }
84
85
  FileType getFileType() {
86
    return mFileType;
87
  }
88
89
  public ExportFormat getExportFormat() {
90
    return mExportFormat;
91
  }
92
93
  @SuppressWarnings("SameParameterValue")
94
  boolean isExportFormat( final ExportFormat format ) {
95
    return mExportFormat == format;
96
  }
97
}
198
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
2929
3030
import com.keenwrite.AbstractFileFactory;
31
import com.keenwrite.FileEditorTab;
3231
import com.keenwrite.preview.HTMLPreviewPane;
3332
import com.keenwrite.processors.markdown.MarkdownProcessor;
3433
34
import java.nio.file.Path;
3535
import java.util.Map;
36
37
import static com.keenwrite.ExportFormat.NONE;
3638
3739
/**
3840
 * Responsible for creating processors capable of parsing, transforming,
3941
 * interpolating, and rendering known file types.
4042
 */
4143
public class ProcessorFactory extends AbstractFileFactory {
4244
43
  private final HTMLPreviewPane mPreviewPane;
44
  private final Map<String, String> mResolvedMap;
45
  private final ProcessorContext mProcessorContext;
4546
  private final Processor<String> mMarkdownProcessor;
4647
4748
  /**
4849
   * Constructs a factory with the ability to create processors that can perform
4950
   * text and caret processing to generate a final preview.
5051
   *
51
   * @param previewPane Where the final output is rendered.
52
   * @param resolvedMap Flat map of definitions to replace before final render.
52
   * @param processorContext Parameters needed to construct various processors.
5353
   */
54
  public ProcessorFactory(
55
      final HTMLPreviewPane previewPane,
56
      final Map<String, String> resolvedMap ) {
57
    mPreviewPane = previewPane;
58
    mResolvedMap = resolvedMap;
54
  private ProcessorFactory( final ProcessorContext processorContext ) {
55
    mProcessorContext = processorContext;
5956
    mMarkdownProcessor = createMarkdownProcessor();
57
  }
58
59
  private Processor<String> createProcessor() {
60
    final ProcessorContext context = getProcessorContext();
61
    final Processor<String> successor;
62
63
    if( context.isExportFormat( NONE ) ) {
64
      // If the content is not to be exported, then the successor processor
65
      // is one that parses Markdown into HTML and passes the string to the
66
      // HTML preview pane.
67
      successor = getCommonProcessor();
68
    }
69
    else {
70
      // Otherwise, bolt on a processor that--after the interpolation and
71
      // substitution phase, which includes text strings or R code---will
72
      // generate HTML or plain Markdown. HTML has a few output formats:
73
      // with embedded SVG representing formulas, or without any conversion
74
      // to SVG. Without conversion would require client-side rendering of
75
      // math (such as using the JavaScript-based KaTeX engine).
76
      successor = switch( context.getExportFormat()   ) {
77
        case HTML_TEX_SVG -> createHtmlSvgProcessor();
78
        case HTML_TEX_DELIMITED -> createHtmlTexProcessor();
79
        case MARKDOWN_PLAIN -> createMarkdownPlainProcessor();
80
        case NONE -> null;
81
      };
82
    }
83
84
    return switch( context.getFileType() ) {
85
      case RMARKDOWN -> createRProcessor( successor );
86
      case SOURCE -> createMarkdownDefinitionProcessor( successor );
87
      case RXML -> createRXMLProcessor( successor );
88
      case XML -> createXMLProcessor( successor );
89
      default -> createPreformattedProcessor();
90
    };
6091
  }
6192
6293
  /**
6394
   * Creates a processor chain suitable for parsing and rendering the file
6495
   * opened at the given tab.
6596
   *
66
   * @param tab The tab containing a text editor, path, and caret position.
97
   * @param context The tab containing a text editor, path, and caret position.
6798
   * @return A processor that can render the given tab's text.
6899
   */
69
  public Processor<String> createProcessors( final FileEditorTab tab ) {
70
    return switch( lookup( tab.getPath() ) ) {
71
      case RMARKDOWN -> createRProcessor();
72
      case SOURCE -> createMarkdownDefinitionProcessor();
73
      case XML -> createXMLProcessor( tab );
74
      case RXML -> createRXMLProcessor( tab );
75
      default -> createIdentityProcessor();
76
    };
100
  public static Processor<String> createProcessors(
101
      final ProcessorContext context ) {
102
    return new ProcessorFactory( context ).createProcessor();
103
  }
104
105
  /**
106
   * Executes the processing chain, operating on the given string.
107
   *
108
   * @param handler The first processor in the chain to call.
109
   * @param text    The initial value of the text to process.
110
   * @return The final value of the text that was processed by the chain.
111
   */
112
  public static String processChain( Processor<String> handler, String text ) {
113
    while( handler != null && text != null ) {
114
      text = handler.apply( text );
115
      handler = handler.next();
116
    }
117
118
    return text;
119
  }
120
121
  /**
122
   * Instantiates a new {@link Processor} that has no successor and returns
123
   * the string it was given without modification.
124
   *
125
   * @return An instance of {@link Processor} that performs no processing.
126
   */
127
  private Processor<String> createIdentityProcessor() {
128
    return new IdentityProcessor( null );
77129
  }
78130
131
  /**
132
   * Instantiates a new {@link Processor} that passes an incoming HTML
133
   * string to a user interface widget that can render HTML as a web page.
134
   *
135
   * @return An instance of {@link Processor} that forwards HTML for display.
136
   */
79137
  private Processor<String> createHTMLPreviewProcessor() {
80138
    return new HtmlPreviewProcessor( getPreviewPane() );
81139
  }
82140
83141
  /**
84
   * Creates and links the processors at the end of the processing chain.
142
   * Instantiates {@link Processor} instances that end the processing chain.
85143
   *
86
   * @return A markdown, caret replacement, and preview pane processor chain.
144
   * @return A chain of {@link Processor}s that convert Markdown to HTML.
87145
   */
88146
  private Processor<String> createMarkdownProcessor() {
89147
    final var hpp = createHTMLPreviewProcessor();
90
    return new MarkdownProcessor( hpp, getPreviewPane().getPath() );
148
    return MarkdownProcessor.create( hpp, getProcessorContext() );
91149
  }
92150
93
  protected Processor<String> createIdentityProcessor() {
94
    final var hpp = createHTMLPreviewProcessor();
95
    return new IdentityProcessor( hpp );
151
  private Processor<String> createPreformattedProcessor(
152
      final Processor<String> successor ) {
153
    return new PreformattedProcessor( successor );
96154
  }
97155
98
  protected Processor<String> createDefinitionProcessor(
99
      final Processor<String> p ) {
100
    return new DefinitionProcessor( p, getResolvedMap() );
156
  private Processor<String> createPreformattedProcessor() {
157
    return createPreformattedProcessor( createHTMLPreviewProcessor() );
101158
  }
102159
103
  protected Processor<String> createMarkdownDefinitionProcessor() {
104
    final var tpc = getCommonProcessor();
105
    return createDefinitionProcessor( tpc );
160
  private Processor<String> createDefinitionProcessor(
161
      final Processor<String> successor ) {
162
    return new DefinitionProcessor( successor, getResolvedMap() );
106163
  }
107164
108
  protected Processor<String> createXMLProcessor( final FileEditorTab tab ) {
109
    final var tpc = getCommonProcessor();
110
    final var xmlp = new XmlProcessor( tpc, tab.getPath() );
111
    return createDefinitionProcessor( xmlp );
165
  private Processor<String> createRProcessor(
166
      final Processor<String> successor ) {
167
    final var rp = new InlineRProcessor( successor, getResolvedMap() );
168
    return new RVariableProcessor( rp, getResolvedMap() );
112169
  }
113170
114
  protected Processor<String> createRProcessor() {
115
    final var tpc = getCommonProcessor();
116
    final var rp = new InlineRProcessor( tpc, getResolvedMap() );
117
    return new RVariableProcessor( rp, getResolvedMap() );
171
  private Processor<String> createMarkdownDefinitionProcessor(
172
      final Processor<String> successor ) {
173
    return createDefinitionProcessor( successor );
118174
  }
119175
120
  protected Processor<String> createRXMLProcessor( final FileEditorTab tab ) {
121
    final var tpc = getCommonProcessor();
122
    final var xmlp = new XmlProcessor( tpc, tab.getPath() );
176
  protected Processor<String> createRXMLProcessor(
177
      final Processor<String> successor ) {
178
    final var xmlp = new XmlProcessor( successor, getPath() );
123179
    final var rp = new InlineRProcessor( xmlp, getResolvedMap() );
124180
    return new RVariableProcessor( rp, getResolvedMap() );
181
  }
182
183
  private Processor<String> createXMLProcessor(
184
      final Processor<String> successor ) {
185
    final var xmlp = new XmlProcessor( successor, getPath() );
186
    return createDefinitionProcessor( xmlp );
187
  }
188
189
  private Processor<String> createHtmlSvgProcessor() {
190
    return MarkdownProcessor.create( null, getProcessorContext() );
191
  }
192
193
  private Processor<String> createHtmlTexProcessor() {
194
    return MarkdownProcessor.create( null, getProcessorContext() );
195
  }
196
197
  private Processor<String> createMarkdownPlainProcessor() {
198
    return createIdentityProcessor();
199
  }
200
201
  /**
202
   * Returns the {@link Processor} common to all {@link Processor}s: markdown
203
   * and an HTML preview renderer.
204
   *
205
   * @return {@link Processor}s at the end of the processing chain.
206
   */
207
  private Processor<String> getCommonProcessor() {
208
    return mMarkdownProcessor;
209
  }
210
211
  private ProcessorContext getProcessorContext() {
212
    return mProcessorContext;
125213
  }
126214
127215
  private HTMLPreviewPane getPreviewPane() {
128
    return mPreviewPane;
216
    return getProcessorContext().getPreviewPane();
129217
  }
130218
131219
  /**
132220
   * Returns the variable map of interpolated definitions.
133221
   *
134222
   * @return A map to help dereference variables.
135223
   */
136224
  private Map<String, String> getResolvedMap() {
137
    return mResolvedMap;
225
    return getProcessorContext().getResolvedMap();
138226
  }
139227
140228
  /**
141
   * Returns a processor common to all processors: markdown, caret position
142
   * token replacer, and an HTML preview renderer.
229
   * Returns the {@link Path} from the {@link ProcessorContext}.
143230
   *
144
   * @return Processors at the end of the processing chain.
231
   * @return A non-null {@link Path} instance.
145232
   */
146
  private Processor<String> getCommonProcessor() {
147
    return mMarkdownProcessor;
233
  private Path getPath() {
234
    return getProcessorContext().getPath();
148235
  }
149236
}
M src/main/java/com/keenwrite/processors/markdown/ImageLinkExtension.java
4444
import java.nio.file.Path;
4545
46
import static com.keenwrite.StatusBarNotifier.alert;
46
import static com.keenwrite.StatusBarNotifier.clue;
4747
import static com.keenwrite.util.ProtocolResolver.getProtocol;
4848
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
...
160160
        return valid( link, url );
161161
      } catch( final Exception ex ) {
162
        alert( ex );
162
        clue( ex );
163163
      }
164164
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
2828
package com.keenwrite.processors.markdown;
2929
30
import com.keenwrite.ExportFormat;
3031
import com.keenwrite.processors.AbstractProcessor;
3132
import com.keenwrite.processors.Processor;
33
import com.keenwrite.processors.ProcessorContext;
3234
import com.vladsch.flexmark.ext.definition.DefinitionExtension;
3335
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
3436
import com.vladsch.flexmark.ext.superscript.SuperscriptExtension;
3537
import com.vladsch.flexmark.ext.tables.TablesExtension;
3638
import com.vladsch.flexmark.ext.typographic.TypographicExtension;
3739
import com.vladsch.flexmark.html.HtmlRenderer;
3840
import com.vladsch.flexmark.parser.Parser;
3941
import com.vladsch.flexmark.util.ast.IParse;
42
import com.vladsch.flexmark.util.ast.IRender;
4043
import com.vladsch.flexmark.util.ast.Node;
4144
import com.vladsch.flexmark.util.misc.Extension;
4245
4346
import java.nio.file.Path;
4447
import java.util.ArrayList;
4548
import java.util.Collection;
4649
4750
import static com.keenwrite.Constants.USER_DIRECTORY;
51
import static com.keenwrite.ExportFormat.NONE;
4852
4953
/**
5054
 * Responsible for parsing a Markdown document and rendering it as HTML.
5155
 */
5256
public class MarkdownProcessor extends AbstractProcessor<String> {
5357
54
  private final HtmlRenderer mRenderer;
58
  private final IRender mRenderer;
5559
  private final IParse mParser;
5660
57
  public MarkdownProcessor(
58
      final Processor<String> successor ) {
59
    this( successor, Path.of( USER_DIRECTORY ) );
61
  public static MarkdownProcessor create() {
62
    return create( null, Path.of( USER_DIRECTORY ) );
6063
  }
6164
62
  /**
63
   * Constructs a new Markdown processor that can create HTML documents.
64
   *
65
   * @param successor Usually the HTML Preview Processor.
66
   */
67
  public MarkdownProcessor(
65
  public static MarkdownProcessor create(
6866
      final Processor<String> successor, final Path path ) {
69
    super( successor );
67
    final var extensions = createExtensions( path, NONE );
7068
71
    // Standard extensions
72
    final Collection<Extension> extensions = new ArrayList<>();
73
    extensions.add( DefinitionExtension.create() );
74
    extensions.add( StrikethroughSubscriptExtension.create() );
75
    extensions.add( SuperscriptExtension.create() );
76
    extensions.add( TablesExtension.create() );
77
    extensions.add( TypographicExtension.create() );
69
    return new MarkdownProcessor( successor, extensions );
70
  }
71
72
  public static MarkdownProcessor create(
73
      final Processor<String> successor, final ProcessorContext context ) {
74
    final var extensions = createExtensions( context );
75
76
    // Allows referencing image files via relative paths and dynamic file types.
77
    extensions.add( ImageLinkExtension.create( context.getPath() ) );
78
    extensions.add( BlockExtension.create() );
79
    extensions.add( TeXExtension.create( context.getExportFormat() ) );
80
81
    return new MarkdownProcessor( successor, extensions );
82
  }
83
84
  private static Collection<Extension> createExtensions(
85
      final ProcessorContext context ) {
86
    return createExtensions( context.getPath(), context.getExportFormat() );
87
  }
88
89
  private static Collection<Extension> createExtensions(
90
      final Path path, final ExportFormat format ) {
91
    final var extensions = createDefaultExtensions();
7892
7993
    // Allows referencing image files via relative paths and dynamic file types.
8094
    extensions.add( ImageLinkExtension.create( path ) );
8195
    extensions.add( BlockExtension.create() );
82
    extensions.add( TeXExtension.create() );
96
    extensions.add( TeXExtension.create( format ) );
97
98
    return extensions;
99
  }
100
101
  public MarkdownProcessor(
102
      final Processor<String> successor,
103
      final Collection<Extension> extensions ) {
104
    super( successor );
83105
84106
    // TODO: https://github.com/FAlthausen/Vollkorn-Typeface/issues/38
85
    // TODO: Uncomment when Vollkorn ligatures are fixed.
107
    // TODO: Uncomment when ligatures are fixed.
86108
    // extensions.add( LigatureExtension.create() );
87109
88110
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
89
    mParser = Parser.builder()
90
                    .extensions( extensions )
91
                    .build();
111
    mParser = Parser.builder().extensions( extensions ).build();
112
  }
113
114
  /**
115
   * Instantiates a number of extensions to be applied when parsing. These
116
   * are typically typographic extensions that convert characters into
117
   * HTML entities.
118
   *
119
   * @return A {@link Collection} of {@link Extension} instances that
120
   * change the {@link Parser}'s behaviour.
121
   */
122
  private static Collection<Extension> createDefaultExtensions() {
123
    final var extensions = new ArrayList<Extension>();
124
    extensions.add( DefinitionExtension.create() );
125
    extensions.add( StrikethroughSubscriptExtension.create() );
126
    extensions.add( SuperscriptExtension.create() );
127
    extensions.add( TablesExtension.create() );
128
    extensions.add( TypographicExtension.create() );
129
    return extensions;
92130
  }
93131
...
145183
  }
146184
147
  private HtmlRenderer getRenderer() {
185
  private IRender getRenderer() {
148186
    return mRenderer;
149187
  }
M src/main/java/com/keenwrite/processors/markdown/TeXExtension.java
2828
package com.keenwrite.processors.markdown;
2929
30
import com.keenwrite.ExportFormat;
3031
import com.keenwrite.processors.markdown.tex.TeXInlineDelimiterProcessor;
31
import com.keenwrite.processors.markdown.tex.TeXNodeRenderer;
32
import com.keenwrite.processors.markdown.tex.TexNodeRenderer.Factory;
3233
import com.vladsch.flexmark.html.HtmlRenderer;
3334
import com.vladsch.flexmark.parser.Parser;
3435
import com.vladsch.flexmark.util.data.MutableDataHolder;
36
import com.vladsch.flexmark.util.misc.Extension;
3537
import org.jetbrains.annotations.NotNull;
3638
...
4850
 */
4951
public class TeXExtension implements ParserExtension, HtmlRendererExtension {
52
  /**
53
   * Controls how the node renderer produces TeX code within HTML output.
54
   */
55
  private final ExportFormat mExportFormat;
56
5057
  /**
5158
   * Creates an extension capable of handling delimited TeX code in Markdown.
5259
   *
5360
   * @return The new {@link TeXExtension}, never {@code null}.
5461
   */
55
  public static TeXExtension create() {
56
    return new TeXExtension();
62
  public static TeXExtension create( final ExportFormat format ) {
63
    return new TeXExtension( format );
5764
  }
5865
5966
  /**
60
   * Force using the {@link #create()} method for consistency.
67
   * Force using the {@link #create(ExportFormat)} method for consistency with
68
   * the other {@link Extension} creation invocations.
6169
   */
62
  private TeXExtension() {
70
  private TeXExtension( final ExportFormat exportFormat ) {
71
    mExportFormat = exportFormat;
6372
  }
6473
...
7382
                      @NotNull final String rendererType ) {
7483
    if( "HTML".equalsIgnoreCase( rendererType ) ) {
75
      builder.nodeRendererFactory( new TeXNodeRenderer.Factory() );
84
      builder.nodeRendererFactory( new Factory( mExportFormat ) );
7685
    }
7786
  }
M src/main/java/com/keenwrite/processors/markdown/tex/TeXInlineDelimiterProcessor.java
3737
3838
  @Override
39
  public void process( final Delimiter opener, final Delimiter closer,
39
  public void process( final Delimiter opener,
40
                       final Delimiter closer,
4041
                       final int delimitersUsed ) {
41
    final var node = new TeXNode();
42
    opener.moveNodesBetweenDelimitersTo(node, closer);
42
    final var node = new TexNode();
43
    opener.moveNodesBetweenDelimitersTo( node, closer );
4344
  }
4445
D src/main/java/com/keenwrite/processors/markdown/tex/TeXNode.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.processors.markdown.tex;
29
30
import com.vladsch.flexmark.ast.DelimitedNodeImpl;
31
32
public class TeXNode extends DelimitedNodeImpl {
33
  /**
34
   * TeX expression wrapped in a {@code <tex>} element.
35
   */
36
  public static final String HTML_TEX = "tex";
37
38
  public TeXNode() {
39
  }
40
}
411
D src/main/java/com/keenwrite/processors/markdown/tex/TeXNodeRenderer.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.processors.markdown.tex;
29
30
import com.vladsch.flexmark.html.HtmlWriter;
31
import com.vladsch.flexmark.html.renderer.NodeRenderer;
32
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
33
import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
34
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
35
import com.vladsch.flexmark.util.data.DataHolder;
36
import org.jetbrains.annotations.NotNull;
37
import org.jetbrains.annotations.Nullable;
38
39
import java.util.Set;
40
41
import static com.keenwrite.processors.markdown.tex.TeXNode.HTML_TEX;
42
43
public class TeXNodeRenderer implements NodeRenderer {
44
45
  public static class Factory implements NodeRendererFactory {
46
    @NotNull
47
    @Override
48
    public NodeRenderer apply( @NotNull DataHolder options ) {
49
      return new TeXNodeRenderer();
50
    }
51
  }
52
53
  @Override
54
  public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
55
    return Set.of( new NodeRenderingHandler<>( TeXNode.class, this::render ) );
56
  }
57
58
  private void render( final TeXNode node,
59
                       final NodeRendererContext context,
60
                       final HtmlWriter html ) {
61
    html.tag( HTML_TEX );
62
    html.raw( node.getText() );
63
    html.closeTag( HTML_TEX );
64
  }
65
}
661
A src/main/java/com/keenwrite/processors/markdown/tex/TexNode.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.processors.markdown.tex;
29
30
import com.vladsch.flexmark.ast.DelimitedNodeImpl;
31
32
public class TexNode extends DelimitedNodeImpl {
33
  /**
34
   * TeX expression wrapped in a {@code <tex>} element.
35
   */
36
  public static final String HTML_TEX = "tex";
37
38
  public static final String TOKEN_OPEN = "$";
39
  public static final String TOKEN_CLOSE = "$";
40
41
  public TexNode() {
42
  }
43
}
144
A src/main/java/com/keenwrite/processors/markdown/tex/TexNodeRenderer.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.processors.markdown.tex;
29
30
import com.keenwrite.ExportFormat;
31
import com.keenwrite.preview.SvgRasterizer;
32
import com.keenwrite.preview.SvgReplacedElementFactory;
33
import com.vladsch.flexmark.html.HtmlWriter;
34
import com.vladsch.flexmark.html.renderer.NodeRenderer;
35
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
36
import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
37
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
38
import com.vladsch.flexmark.util.ast.Node;
39
import com.vladsch.flexmark.util.data.DataHolder;
40
import org.jetbrains.annotations.NotNull;
41
import org.jetbrains.annotations.Nullable;
42
43
import java.util.Set;
44
45
import static com.keenwrite.processors.markdown.tex.TexNode.*;
46
47
public class TexNodeRenderer {
48
49
  public static class Factory implements NodeRendererFactory {
50
    private final ExportFormat mExportFormat;
51
52
    public Factory( final ExportFormat exportFormat ) {
53
      mExportFormat = exportFormat;
54
    }
55
56
    @NotNull
57
    @Override
58
    public NodeRenderer apply( @NotNull DataHolder options ) {
59
      return switch( mExportFormat ) {
60
        case HTML_TEX_SVG -> new TexSvgNodeRenderer();
61
        case HTML_TEX_DELIMITED, MARKDOWN_PLAIN -> new TexDelimNodeRenderer();
62
        case NONE -> new TexElementNodeRenderer();
63
      };
64
    }
65
  }
66
67
  private static abstract class AbstractTexNodeRenderer
68
      implements NodeRenderer {
69
70
    @Override
71
    public @Nullable Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
72
      final var h = new NodeRenderingHandler<>( TexNode.class, this::render );
73
      return Set.of( h );
74
    }
75
76
    /**
77
     * Subclasses implement this method to render the content of {@link TexNode}
78
     * instances as per their associated {@link ExportFormat}.
79
     *
80
     * @param node    {@link Node} containing text content of a math formula.
81
     * @param context Configuration information (unused).
82
     * @param html    Where to write the rendered output.
83
     */
84
    abstract void render( final TexNode node,
85
                          final NodeRendererContext context,
86
                          final HtmlWriter html );
87
  }
88
89
  /**
90
   * Responsible for rendering a TeX node as an HTML {@code <tex>}
91
   * element. This is the default behaviour.
92
   */
93
  private static class TexElementNodeRenderer extends AbstractTexNodeRenderer {
94
    void render( final TexNode node,
95
                 final NodeRendererContext context,
96
                 final HtmlWriter html ) {
97
      html.tag( HTML_TEX );
98
      html.raw( node.getText() );
99
      html.closeTag( HTML_TEX );
100
    }
101
  }
102
103
  /**
104
   * Responsible for rendering a TeX node as an HTML {@code <svg>}
105
   * element.
106
   */
107
  private static class TexSvgNodeRenderer extends AbstractTexNodeRenderer {
108
    void render( final TexNode node,
109
                 final NodeRendererContext context,
110
                 final HtmlWriter html ) {
111
      final var renderer = SvgReplacedElementFactory.getInstance();
112
      final var tex = node.getText().toStringOrNull();
113
      final var doc = renderer.render( tex == null ? "" : tex );
114
      final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() );
115
      html.raw( svg );
116
    }
117
  }
118
119
  /**
120
   * Responsible for rendering a TeX node as text bracketed by $ tokens.
121
   */
122
  private static class TexDelimNodeRenderer extends AbstractTexNodeRenderer {
123
    void render( final TexNode node,
124
                 final NodeRendererContext context,
125
                 final HtmlWriter html ) {
126
      html.raw( TOKEN_OPEN );
127
      html.raw( node.getText() );
128
      html.raw( TOKEN_CLOSE );
129
    }
130
  }
131
}
1132
M src/main/java/com/keenwrite/util/Action.java
11
/*
2
 * Copyright 2020 Karl Tauber and White Magic Software, Ltd.
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
34
 * All rights reserved.
45
 *
...
2728
package com.keenwrite.util;
2829
29
import de.jensd.fx.glyphs.GlyphIcons;
30
import javafx.beans.value.ObservableBooleanValue;
31
import javafx.event.ActionEvent;
32
import javafx.event.EventHandler;
33
import javafx.scene.input.KeyCombination;
30
import javafx.scene.Node;
31
import javafx.scene.control.MenuItem;
3432
3533
/**
36
 * Defines actions the user can take by interacting with the GUI.
34
 * Represents a menu action that can generate {@link MenuItem} instances and
35
 * and {@link Node} instances for a toolbar.
3736
 */
38
public class Action {
39
  public final String text;
40
  public final KeyCombination accelerator;
41
  public final GlyphIcons icon;
42
  public final EventHandler<ActionEvent> action;
43
  public final ObservableBooleanValue disable;
37
public abstract class Action {
38
  public abstract MenuItem createMenuItem();
4439
45
  public Action(
46
      final String text,
47
      final String accelerator,
48
      final GlyphIcons icon,
49
      final EventHandler<ActionEvent> action,
50
      final ObservableBooleanValue disable ) {
40
  public abstract Node createToolBarButton();
5141
52
    this.text = text;
53
    this.accelerator = accelerator == null ?
54
        null : KeyCombination.valueOf( accelerator );
55
    this.icon = icon;
56
    this.action = action;
57
    this.disable = disable;
42
  /**
43
   * Adds subordinate actions to the menu. This is used to establish sub-menu
44
   * relationships. The default behaviour does not wire up any registration;
45
   * subclasses are responsible for handling how actions relate to one another.
46
   *
47
   * @param action Actions that only exist with respect to this action.
48
   */
49
  public void addSubActions( Action... action ) {
5850
  }
5951
}
M src/main/java/com/keenwrite/util/ActionBuilder.java
7777
7878
  public Action build() {
79
    return new Action( mText, mAccelerator, mIcon, mAction, mDisable );
79
    return new MenuAction( mText, mAccelerator, mIcon, mAction, mDisable );
8080
  }
8181
}
M src/main/java/com/keenwrite/util/ActionUtils.java
2727
package com.keenwrite.util;
2828
29
import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory;
3029
import javafx.scene.Node;
31
import javafx.scene.control.Button;
3230
import javafx.scene.control.Menu;
3331
import javafx.scene.control.MenuItem;
34
import javafx.scene.control.Separator;
35
import javafx.scene.control.SeparatorMenuItem;
3632
import javafx.scene.control.ToolBar;
37
import javafx.scene.control.Tooltip;
3833
3934
/**
...
4742
4843
  public static MenuItem[] createMenuItems( final Action... actions ) {
49
    final MenuItem[] menuItems = new MenuItem[ actions.length ];
44
    final var menuItems = new MenuItem[ actions.length ];
5045
5146
    for( int i = 0; i < actions.length; i++ ) {
52
      menuItems[ i ] = (actions[ i ] == null)
53
          ? new SeparatorMenuItem()
54
          : createMenuItem( actions[ i ] );
47
      menuItems[ i ] = actions[ i ].createMenuItem();
5548
    }
5649
5750
    return menuItems;
58
  }
59
60
  public static MenuItem createMenuItem( final Action action ) {
61
    final MenuItem menuItem = new MenuItem( action.text );
62
63
    if( action.accelerator != null ) {
64
      menuItem.setAccelerator( action.accelerator );
65
    }
66
67
    if( action.icon != null ) {
68
      menuItem.setGraphic(
69
          FontAwesomeIconFactory.get().createIcon( action.icon ) );
70
    }
71
72
    menuItem.setOnAction( action.action );
73
74
    if( action.disable != null ) {
75
      menuItem.disableProperty().bind( action.disable );
76
    }
77
78
    menuItem.setMnemonicParsing( true );
79
80
    return menuItem;
8151
  }
8252
8353
  public static ToolBar createToolBar( final Action... actions ) {
8454
    return new ToolBar( createToolBarButtons( actions ) );
8555
  }
8656
8757
  public static Node[] createToolBarButtons( final Action... actions ) {
8858
    Node[] buttons = new Node[ actions.length ];
89
    for( int i = 0; i < actions.length; i++ ) {
90
      buttons[ i ] = (actions[ i ] != null)
91
          ? createToolBarButton( actions[ i ] )
92
          : new Separator();
93
    }
94
    return buttons;
95
  }
96
97
  public static Button createToolBarButton( final Action action ) {
98
    final Button button = new Button();
99
    button.setGraphic(
100
        FontAwesomeIconFactory
101
            .get()
102
            .createIcon( action.icon, "1.2em" ) );
103
104
    String tooltip = action.text;
105
106
    if( tooltip.endsWith( "..." ) ) {
107
      tooltip = tooltip.substring( 0, tooltip.length() - 3 );
108
    }
109
110
    if( action.accelerator != null ) {
111
      tooltip += " (" + action.accelerator.getDisplayText() + ')';
112
    }
113
114
    button.setTooltip( new Tooltip( tooltip ) );
115
    button.setFocusTraversable( false );
116
    button.setOnAction( action.action );
11759
118
    if( action.disable != null ) {
119
      button.disableProperty().bind( action.disable );
60
    for( int i = 0; i < actions.length; i++ ) {
61
      buttons[ i ] = actions[ i ].createToolBarButton();
12062
    }
12163
122
    return button;
64
    return buttons;
12365
  }
12466
}
A src/main/java/com/keenwrite/util/MenuAction.java
1
/*
2
 * Copyright 2020 Karl Tauber and White Magic Software, Ltd.
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
package com.keenwrite.util;
28
29
import de.jensd.fx.glyphs.GlyphIcons;
30
import javafx.beans.value.ObservableBooleanValue;
31
import javafx.event.ActionEvent;
32
import javafx.event.EventHandler;
33
import javafx.scene.control.Button;
34
import javafx.scene.control.Menu;
35
import javafx.scene.control.MenuItem;
36
import javafx.scene.control.Tooltip;
37
import javafx.scene.input.KeyCombination;
38
39
import java.util.ArrayList;
40
import java.util.List;
41
42
import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get;
43
import static javafx.scene.input.KeyCombination.valueOf;
44
45
/**
46
 * Defines actions the user can take by interacting with the GUI.
47
 */
48
public class MenuAction extends Action {
49
  private final String mText;
50
  private final KeyCombination mAccelerator;
51
  private final GlyphIcons mIcon;
52
  private final EventHandler<ActionEvent> mAction;
53
  private final ObservableBooleanValue mDisabled;
54
  private final List<Action> mSubActions = new ArrayList<>();
55
56
  public MenuAction(
57
      final String text,
58
      final String accelerator,
59
      final GlyphIcons icon,
60
      final EventHandler<ActionEvent> action,
61
      final ObservableBooleanValue disabled ) {
62
63
    mText = text;
64
    mAccelerator = accelerator == null ? null : valueOf( accelerator );
65
    mIcon = icon;
66
    mAction = action;
67
    mDisabled = disabled;
68
  }
69
70
  @Override
71
  public MenuItem createMenuItem() {
72
    // This will either become a menu or a menu item, depending on whether
73
    // sub-actions are defined.
74
    final MenuItem menuItem;
75
76
    if( mSubActions.isEmpty() ) {
77
      // Regular menu item has no sub-menus.
78
      menuItem = new MenuItem( mText );
79
    }
80
    else {
81
      // Sub-actions are translated into sub-menu items beneath this action.
82
      final var submenu = new Menu( mText );
83
84
      for( final var action : mSubActions ) {
85
        // Recursive call that creates a sub-menu hierarchy.
86
        submenu.getItems().add( action.createMenuItem() );
87
      }
88
89
      menuItem = submenu;
90
    }
91
92
    if( mAccelerator != null ) {
93
      menuItem.setAccelerator( mAccelerator );
94
    }
95
96
    if( mIcon != null ) {
97
      menuItem.setGraphic( get().createIcon( mIcon ) );
98
    }
99
100
    if( mAction != null ) {
101
      menuItem.setOnAction( mAction );
102
    }
103
104
    if( mDisabled != null ) {
105
      menuItem.disableProperty().bind( mDisabled );
106
    }
107
108
    menuItem.setMnemonicParsing( true );
109
110
    return menuItem;
111
  }
112
113
  @Override
114
  public Button createToolBarButton() {
115
    final Button button = new Button();
116
    button.setGraphic(
117
        get().createIcon( mIcon, "1.2em" ) );
118
119
    String tooltip = mText;
120
121
    if( tooltip.endsWith( "..." ) ) {
122
      tooltip = tooltip.substring( 0, tooltip.length() - 3 );
123
    }
124
125
    if( mAccelerator != null ) {
126
      tooltip += " (" + mAccelerator.getDisplayText() + ')';
127
    }
128
129
    button.setTooltip( new Tooltip( tooltip ) );
130
    button.setFocusTraversable( false );
131
    button.setOnAction( mAction );
132
133
    if( mDisabled != null ) {
134
      button.disableProperty().bind( mDisabled );
135
    }
136
137
    return button;
138
  }
139
140
  @Override
141
  public void addSubActions( final Action... action ) {
142
    mSubActions.addAll( List.of( action ) );
143
  }
144
}
1145
A src/main/java/com/keenwrite/util/SeparatorAction.java
1
/*
2
 * Copyright 2020 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.keenwrite.util;
29
30
import javafx.scene.Node;
31
import javafx.scene.control.MenuItem;
32
import javafx.scene.control.Separator;
33
import javafx.scene.control.SeparatorMenuItem;
34
35
/**
36
 * Represents a menu bar action that has no operation, acting as a placeholder
37
 * for line separators.
38
 */
39
public class SeparatorAction extends Action {
40
  @Override
41
  public MenuItem createMenuItem() {
42
    return new SeparatorMenuItem();
43
  }
44
45
  @Override
46
  public Node createToolBarButton() {
47
    return new Separator();
48
  }
49
}
150
M src/main/resources/com/keenwrite/messages.properties
1313
Main.menu.file.save_as=Save _As
1414
Main.menu.file.save_all=Save A_ll
15
Main.menu.file.export=_Export As
16
Main.menu.file.export.html_svg=HTML and S_VG
17
Main.menu.file.export.html_tex=HTML and _TeX
18
Main.menu.file.export.markdown=Markdown
1519
Main.menu.file.exit=E_xit
1620
1721
Main.menu.edit=_Edit
18
Main.menu.edit.copy.html=Copy _HTML
1922
Main.menu.edit.undo=_Undo
2023
Main.menu.edit.redo=_Redo
...
6568
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
6669
Main.status.state.default=OK
70
Main.status.export.success=Saved as {0}
71
6772
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
6873
Main.status.error.def.blank=Move the caret to a word before inserting a definition.
...
137142
Dialog.file.choose.open.title=Open File
138143
Dialog.file.choose.save.title=Save File
144
Dialog.file.choose.export.title=Export File
139145
140146
Dialog.file.choose.filter.title.source=Source Files