Dave Jarvis' Repositories

M .idea/compiler.xml
44
    <bytecodeTargetLevel target="14" />
55
  </component>
6
  <component name="JavacSettings">
7
    <option name="ADDITIONAL_OPTIONS_OVERRIDE">
8
      <module name="scrivenvar.main" options="--add-exports java.desktop/sun.swing=ALL-UNNAMED" />
9
    </option>
10
  </component>
116
</project>
A CODE_OF_CONDUCT.md
1
# Contributor Covenant Code of Conduct
2
3
## Our Pledge
4
5
In the interest of fostering an open and welcoming environment, we as
6
contributors and maintainers pledge to making participation in our project and
7
our community a harassment-free experience for everyone, regardless of age, body
8
size, disability, ethnicity, sex characteristics, gender identity and expression,
9
level of experience, education, socio-economic status, nationality, personal
10
appearance, race, religion, or sexual identity and orientation.
11
12
## Our Standards
13
14
Examples of behavior that contributes to creating a positive environment
15
include:
16
17
* Using welcoming and inclusive language
18
* Being respectful of differing viewpoints and experiences
19
* Gracefully accepting constructive criticism
20
* Focusing on what is best for the community
21
* Showing empathy towards other community members
22
23
Examples of unacceptable behavior by participants include:
24
25
* The use of sexualized language or imagery and unwelcome sexual attention or
26
 advances
27
* Trolling, insulting/derogatory comments, and personal or political attacks
28
* Public or private harassment
29
* Publishing others' private information, such as a physical or electronic
30
 address, without explicit permission
31
* Other conduct which could reasonably be considered inappropriate in a
32
 professional setting
33
34
## Our Responsibilities
35
36
Project maintainers are responsible for clarifying the standards of acceptable
37
behavior and are expected to take appropriate and fair corrective action in
38
response to any instances of unacceptable behavior.
39
40
Project maintainers have the right and responsibility to remove, edit, or
41
reject comments, commits, code, wiki edits, issues, and other contributions
42
that are not aligned to this Code of Conduct, or to ban temporarily or
43
permanently any contributor for other behaviors that they deem inappropriate,
44
threatening, offensive, or harmful.
45
46
## Scope
47
48
This Code of Conduct applies both within project spaces and in public spaces
49
when an individual is representing the project or its community. Examples of
50
representing a project or community include using an official project e-mail
51
address, posting via an official social media account, or acting as an appointed
52
representative at an online or offline event. Representation of a project may be
53
further defined and clarified by project maintainers.
54
55
## Enforcement
56
57
Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
reported by contacting the project team at Dave.Jarvis@gmail.com. All
59
complaints will be reviewed and investigated and will result in a response that
60
is deemed necessary and appropriate to the circumstances. The project team is
61
obligated to maintain confidentiality with regard to the reporter of an incident.
62
Further details of specific enforcement policies may be posted separately.
63
64
Project maintainers who do not follow or enforce the Code of Conduct in good
65
faith may face temporary or permanent repercussions as determined by other
66
members of the project's leadership.
67
68
## Attribution
69
70
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72
73
[homepage]: https://www.contributor-covenant.org
74
75
For answers to common questions about this code of conduct, see
76
https://www.contributor-covenant.org/faq
177
M build.gradle
33
  id 'org.openjfx.javafxplugin' version '0.0.9'
44
  id 'com.palantir.git-version' version '0.12.3'
5
  id 'org.beryx.jlink' version '2.16.2'
65
}
76
...
3736
3837
dependencies {
38
  def v_junit = '5.4.2'
39
  def v_flexmark = '0.62.2'
40
  def v_jackson = '2.11.2'
41
  def v_batik = '1.13'
42
3943
  // JavaFX
4044
  implementation 'org.reactfx:reactfx:1.4.1'
...
5458
5559
  // Markdown
56
  implementation 'com.vladsch.flexmark:flexmark:0.62.2'
57
  implementation 'com.vladsch.flexmark:flexmark-ext-definition:0.62.2'
58
  implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.2'
59
  implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.2'
60
  implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.2'
61
  implementation 'com.vladsch.flexmark:flexmark-ext-typographic:0.62.2'
60
  implementation "com.vladsch.flexmark:flexmark:${v_flexmark}"
61
  implementation "com.vladsch.flexmark:flexmark-ext-definition:${v_flexmark}"
62
  implementation "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:${v_flexmark}"
63
  implementation "com.vladsch.flexmark:flexmark-ext-superscript:${v_flexmark}"
64
  implementation "com.vladsch.flexmark:flexmark-ext-tables:${v_flexmark}"
65
  implementation "com.vladsch.flexmark:flexmark-ext-typographic:${v_flexmark}"
6266
6367
  // YAML
64
  implementation 'com.fasterxml.jackson.core:jackson-core:2.11.2'
65
  implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.2'
66
  implementation 'com.fasterxml.jackson.core:jackson-annotations:2.11.2'
67
  implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.11.2'
68
  implementation "com.fasterxml.jackson.core:jackson-core:${v_jackson}"
69
  implementation "com.fasterxml.jackson.core:jackson-databind:${v_jackson}"
70
  implementation "com.fasterxml.jackson.core:jackson-annotations:${v_jackson}"
71
  implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${v_jackson}"
6872
  implementation 'org.yaml:snakeyaml:1.26'
6973
...
8185
8286
  // SVG
83
  implementation 'org.apache.xmlgraphics:batik-anim:1.13'
84
  implementation 'org.apache.xmlgraphics:batik-awt-util:1.13'
85
  implementation 'org.apache.xmlgraphics:batik-bridge:1.13'
86
  implementation 'org.apache.xmlgraphics:batik-css:1.13'
87
  implementation 'org.apache.xmlgraphics:batik-dom:1.13'
88
  implementation 'org.apache.xmlgraphics:batik-ext:1.13'
89
  implementation 'org.apache.xmlgraphics:batik-gvt:1.13'
90
  implementation 'org.apache.xmlgraphics:batik-parser:1.13'
91
  implementation 'org.apache.xmlgraphics:batik-script:1.13'
92
  implementation 'org.apache.xmlgraphics:batik-svg-dom:1.13'
93
  implementation 'org.apache.xmlgraphics:batik-svggen:1.13'
94
  implementation 'org.apache.xmlgraphics:batik-transcoder:1.13'
95
  implementation 'org.apache.xmlgraphics:batik-util:1.13'
96
  implementation 'org.apache.xmlgraphics:batik-xml:1.13'
87
  implementation "org.apache.xmlgraphics:batik-anim:${v_batik}"
88
  implementation "org.apache.xmlgraphics:batik-awt-util:${v_batik}"
89
  implementation "org.apache.xmlgraphics:batik-bridge:${v_batik}"
90
  implementation "org.apache.xmlgraphics:batik-css:${v_batik}"
91
  implementation "org.apache.xmlgraphics:batik-dom:${v_batik}"
92
  implementation "org.apache.xmlgraphics:batik-ext:${v_batik}"
93
  implementation "org.apache.xmlgraphics:batik-gvt:${v_batik}"
94
  implementation "org.apache.xmlgraphics:batik-parser:${v_batik}"
95
  implementation "org.apache.xmlgraphics:batik-script:${v_batik}"
96
  implementation "org.apache.xmlgraphics:batik-svg-dom:${v_batik}"
97
  implementation "org.apache.xmlgraphics:batik-svggen:${v_batik}"
98
  implementation "org.apache.xmlgraphics:batik-transcoder:${v_batik}"
99
  implementation "org.apache.xmlgraphics:batik-util:${v_batik}"
100
  implementation "org.apache.xmlgraphics:batik-xml:${v_batik}"
97101
98102
  // Spelling, TeX
...
115119
  }
116120
117
  testImplementation('org.junit.jupiter:junit-jupiter-api:5.4.2')
118
  testRuntime('org.junit.jupiter:junit-jupiter-engine:5.4.2')
121
  testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}"
122
  testRuntime "org.junit.jupiter:junit-jupiter-engine:${v_junit}"
119123
}
120124
M docs/README.md
55
* [definitions.md](definitions.md) -- Definitions and interpolation
66
* [r.md](r.md) -- Call R functions within R Markdown documents
7
* [texample.md](texample.md) -- Numerous examples of formulas
7
* [texample.Rmd](texample.Rmd) -- Numerous examples of formulas
88
* [svg.md](svg.md) -- Fix known issues with displaying SVG files
99
* [credits.md](credits.md) -- Thanks to authors of contributing projects
M docs/definitions.md
1818
```
1919
20
Variables can reference other variables by enclosing the key name within dollar symbols:
20
Variables can reference other variables by bookending the key name within symbols:
2121
2222
```
2323
key: Value
24
key_1: $key$ 1
25
key_2: $key$ 2
24
key_1: {{key}} 1
25
key_2: {{key}} 2
2626
```
2727
...
4141
  author: Author Name
4242
copyright:
43
  owner: $novel.author$
43
  owner: {{novel.author}}
4444
```
4545
...
7878
```
7979
novel:
80
  title: Diary of $novel.author$
80
  title: Diary of {{novel.author}}
8181
  author: Anne Frank
8282
```
8383
84
To reference a variable, type in the key name enclosed within dollar symbols, such as:
84
To reference a variable, type in the key name enclosed within double braces, such as:
8585
8686
```
87
The novel "$novel.title$" is one of the most widely read books in the world.
87
The novel "{{novel.title}}" is one of the most widely read books in the world.
8888
```
8989
...
101101
102102
```
103
$novel.title$
103
{{novel.title}}
104104
```
105105
M docs/r.md
9090
1. Set the **R Startup Script** contents to:
9191
    ``` r
92
    setwd( '$application.r.working.directory$' );
92
    setwd( '{{application.r.working.directory}}' );
9393
    source( 'library.R' );
9494
    ```
...
107107
```
108108
109
Calling `setwd` using `'$application.r.working.directory$'` changes the
109
Calling `setwd` using `'{{application.r.working.directory}}'` changes the
110110
working directory where the R engine searches for source files.
111111
M libs/jmathtex/jmathtex.jar
Binary file
M src/main/java/com/keenwrite/Constants.java
135135
136136
  /**
137
   * Default starting delimiter for definition variables.
137
   * Default starting delimiter for definition variables. This value must
138
   * not overlap math delimiters, so do not use $ tokens as the first
139
   * delimiter.
138140
   */
139
  public static final String DEF_DELIM_BEGAN_DEFAULT = "${";
141
  public static final String DEF_DELIM_BEGAN_DEFAULT = "{{";
140142
141143
  /**
142144
   * Default ending delimiter for definition variables.
143145
   */
144
  public static final String DEF_DELIM_ENDED_DEFAULT = "}";
146
  public static final String DEF_DELIM_ENDED_DEFAULT = "}}";
145147
146148
  /**
...
158160
   */
159161
  public static final String LEXICONS_DIRECTORY = "lexicons";
160
161
  /**
162
   * Used as the prefix for uniquely identifying HTML block elements, which
163
   * helps coordinate scrolling the preview pane to where the user is typing.
164
   */
165
  public static final String PARAGRAPH_ID_PREFIX = "p-";
166162
167163
  /**
...
174170
   */
175171
  public static final float FONT_SIZE_EDITOR = 12f;
172
173
  /**
174
   * Default identifier to use for synchronized scrolling.
175
   */
176
  public static String CARET_ID = "caret";
176177
177178
  /**
M src/main/java/com/keenwrite/ExportFormat.java
6464
  private final String mExtension;
6565
66
  private ExportFormat( final String extension ) {
66
  ExportFormat( final String extension ) {
6767
    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;
7668
  }
7769
M src/main/java/com/keenwrite/FileEditorTab.java
2828
import com.keenwrite.editors.EditorPane;
2929
import com.keenwrite.editors.markdown.MarkdownEditorPane;
30
import com.keenwrite.service.events.Notification;
31
import com.keenwrite.service.events.Notifier;
32
import javafx.beans.binding.Bindings;
33
import javafx.beans.property.BooleanProperty;
34
import javafx.beans.property.ReadOnlyBooleanProperty;
35
import javafx.beans.property.ReadOnlyBooleanWrapper;
36
import javafx.beans.property.SimpleBooleanProperty;
37
import javafx.beans.value.ChangeListener;
38
import javafx.event.Event;
39
import javafx.event.EventHandler;
40
import javafx.event.EventType;
41
import javafx.scene.Scene;
42
import javafx.scene.control.Tab;
43
import javafx.scene.control.Tooltip;
44
import javafx.scene.text.Text;
45
import javafx.stage.Window;
46
import org.fxmisc.flowless.VirtualizedScrollPane;
47
import org.fxmisc.richtext.StyleClassedTextArea;
48
import org.fxmisc.undo.UndoManager;
49
import org.jetbrains.annotations.NotNull;
50
import org.mozilla.universalchardet.UniversalDetector;
51
52
import java.io.File;
53
import java.nio.charset.Charset;
54
import java.nio.file.Files;
55
import java.nio.file.Path;
56
57
import static com.keenwrite.Messages.get;
58
import static com.keenwrite.StatusBarNotifier.clue;
59
import static com.keenwrite.StatusBarNotifier.getNotifier;
60
import static java.nio.charset.StandardCharsets.UTF_8;
61
import static java.util.Locale.ENGLISH;
62
import static javafx.application.Platform.runLater;
63
64
/**
65
 * Editor for a single file.
66
 */
67
public final class FileEditorTab extends Tab {
68
69
  private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
70
71
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
72
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
73
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
74
75
  /**
76
   * Character encoding used by the file (or default encoding if none found).
77
   */
78
  private Charset mEncoding = UTF_8;
79
80
  /**
81
   * File to load into the editor.
82
   */
83
  private Path mPath;
84
85
  public FileEditorTab( final Path path ) {
86
    setPath( path );
87
88
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
89
90
    setOnSelectionChanged( e -> {
91
      if( isSelected() ) {
92
        runLater( this::activated );
93
        requestFocus();
94
      }
95
    } );
96
  }
97
98
  private void updateTab() {
99
    setText( getTabTitle() );
100
    setGraphic( getModifiedMark() );
101
    setTooltip( getTabTooltip() );
102
  }
103
104
  /**
105
   * Returns the base filename (without the directory names).
106
   *
107
   * @return The untitled text if the path hasn't been set.
108
   */
109
  private String getTabTitle() {
110
    return getPath().getFileName().toString();
111
  }
112
113
  /**
114
   * Returns the full filename represented by the path.
115
   *
116
   * @return The untitled text if the path hasn't been set.
117
   */
118
  private Tooltip getTabTooltip() {
119
    final Path filePath = getPath();
120
    return new Tooltip( filePath == null ? "" : filePath.toString() );
121
  }
122
123
  /**
124
   * Returns a marker to indicate whether the file has been modified.
125
   *
126
   * @return "*" when the file has changed; otherwise null.
127
   */
128
  private Text getModifiedMark() {
129
    return isModified() ? new Text( "*" ) : null;
130
  }
131
132
  /**
133
   * Called when the user switches tab.
134
   */
135
  private void activated() {
136
    // Tab is closed or no longer active.
137
    if( getTabPane() == null || !isSelected() ) {
138
      return;
139
    }
140
141
    // If the tab is devoid of content, load it.
142
    if( getContent() == null ) {
143
      readFile();
144
      initLayout();
145
      initUndoManager();
146
    }
147
  }
148
149
  private void initLayout() {
150
    setContent( getScrollPane() );
151
  }
152
153
  /**
154
   * Tracks undo requests, but can only be called <em>after</em> load.
155
   */
156
  private void initUndoManager() {
157
    final UndoManager<?> undoManager = getUndoManager();
158
    undoManager.forgetHistory();
159
160
    // Bind the editor undo manager to the properties.
161
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
162
    canUndo.bind( undoManager.undoAvailableProperty() );
163
    canRedo.bind( undoManager.redoAvailableProperty() );
164
  }
165
166
  private void requestFocus() {
167
    getEditorPane().requestFocus();
168
  }
169
170
  /**
171
   * Searches from the caret position forward for the given string.
172
   *
173
   * @param needle The text string to match.
174
   */
175
  public void searchNext( final String needle ) {
176
    final String haystack = getEditorText();
177
    int index = haystack.indexOf( needle, getCaretPosition() );
178
179
    // Wrap around.
180
    if( index == -1 ) {
181
      index = haystack.indexOf( needle );
182
    }
183
184
    if( index >= 0 ) {
185
      setCaretPosition( index );
186
      getEditor().selectRange( index, index + needle.length() );
187
    }
188
  }
189
190
  /**
191
   * Gets a reference to the scroll pane that houses the editor.
192
   *
193
   * @return The editor's scroll pane, containing a vertical scrollbar.
194
   */
195
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
196
    return getEditorPane().getScrollPane();
197
  }
198
199
  /**
200
   * Returns the index into the text where the caret blinks happily away.
201
   *
202
   * @return A number from 0 to the editor's document text length.
203
   */
204
  public int getCaretPosition() {
205
    return getEditor().getCaretPosition();
206
  }
207
208
  /**
209
   * Moves the caret to a given offset.
210
   *
211
   * @param offset The new caret offset.
212
   */
213
  private void setCaretPosition( final int offset ) {
214
    getEditor().moveTo( offset );
215
    getEditor().requestFollowCaret();
216
  }
217
218
  /**
219
   * Returns the text area associated with this tab.
220
   *
221
   * @return A text editor.
222
   */
223
  private StyleClassedTextArea getEditor() {
224
    return getEditorPane().getEditor();
225
  }
226
227
  /**
228
   * Returns true if the given path exactly matches this tab's path.
229
   *
230
   * @param check The path to compare against.
231
   * @return true The paths are the same.
232
   */
233
  public boolean isPath( final Path check ) {
234
    final Path filePath = getPath();
235
236
    return filePath != null && filePath.equals( check );
237
  }
238
239
  /**
240
   * Reads the entire file contents from the path associated with this tab.
241
   */
242
  private void readFile() {
243
    final Path path = getPath();
244
    final File file = path.toFile();
245
246
    try {
247
      if( file.exists() ) {
248
        if( file.canWrite() && file.canRead() ) {
249
          final EditorPane pane = getEditorPane();
250
          pane.setText( asString( Files.readAllBytes( path ) ) );
251
          pane.scrollToTop();
252
        }
253
        else {
254
          final String msg = get( "FileEditor.loadFailed.reason.permissions" );
255
          clue( "FileEditor.loadFailed.message", file.toString(), msg );
256
        }
257
      }
258
    } catch( final Exception ex ) {
259
      clue( ex );
260
    }
261
  }
262
263
  /**
264
   * Saves the entire file contents from the path associated with this tab.
265
   *
266
   * @return true The file has been saved.
267
   */
268
  public boolean save() {
269
    try {
270
      final EditorPane editor = getEditorPane();
271
      Files.write( getPath(), asBytes( editor.getText() ) );
272
      editor.getUndoManager().mark();
273
      return true;
274
    } catch( final Exception ex ) {
275
      return popupAlert(
276
          "FileEditor.saveFailed.title",
277
          "FileEditor.saveFailed.message",
278
          ex
279
      );
280
    }
281
  }
282
283
  /**
284
   * Creates an alert dialog and waits for it to close.
285
   *
286
   * @param titleKey   Resource bundle key for the alert dialog title.
287
   * @param messageKey Resource bundle key for the alert dialog message.
288
   * @param e          The unexpected happening.
289
   * @return false
290
   */
291
  @SuppressWarnings("SameParameterValue")
292
  private boolean popupAlert(
293
      final String titleKey, final String messageKey, final Exception e ) {
294
    final Notifier service = getNotifier();
295
    final Path filePath = getPath();
296
297
    final Notification message = service.createNotification(
298
        get( titleKey ),
299
        get( messageKey ),
300
        filePath == null ? "" : filePath,
301
        e.getMessage()
302
    );
303
304
    try {
305
      service.createError( getWindow(), message ).showAndWait();
306
    } catch( final Exception ex ) {
307
      clue( ex );
308
    }
309
310
    return false;
311
  }
312
313
  private Window getWindow() {
314
    final Scene scene = getEditorPane().getScene();
315
316
    if( scene == null ) {
317
      throw new UnsupportedOperationException( "No scene window available" );
318
    }
319
320
    return scene.getWindow();
321
  }
322
323
  /**
324
   * Returns a best guess at the file encoding. If the encoding could not be
325
   * detected, this will return the default charset for the JVM.
326
   *
327
   * @param bytes The bytes to perform character encoding detection.
328
   * @return The character encoding.
329
   */
330
  private Charset detectEncoding( final byte[] bytes ) {
331
    final var detector = new UniversalDetector( null );
332
    detector.handleData( bytes, 0, bytes.length );
333
    detector.dataEnd();
334
335
    final String charset = detector.getDetectedCharset();
336
337
    return charset == null
338
        ? Charset.defaultCharset()
339
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
340
  }
341
342
  /**
343
   * Converts the given string to an array of bytes using the encoding that was
344
   * originally detected (if any) and associated with this file.
345
   *
346
   * @param text The text to convert into the original file encoding.
347
   * @return A series of bytes ready for writing to a file.
348
   */
349
  private byte[] asBytes( final String text ) {
350
    return text.getBytes( getEncoding() );
351
  }
352
353
  /**
354
   * Converts the given bytes into a Java String. This will call setEncoding
355
   * with the encoding detected by the CharsetDetector.
356
   *
357
   * @param text The text of unknown character encoding.
358
   * @return The text, in its auto-detected encoding, as a String.
359
   */
360
  private String asString( final byte[] text ) {
361
    setEncoding( detectEncoding( text ) );
362
    return new String( text, getEncoding() );
363
  }
364
365
  /**
366
   * Returns the path to the file being edited in this tab.
367
   *
368
   * @return A non-null instance.
369
   */
370
  public Path getPath() {
371
    return mPath;
372
  }
373
374
  /**
375
   * Sets the path to a file for editing and then updates the tab with the
376
   * file contents.
377
   *
378
   * @param path A non-null instance.
379
   */
380
  public void setPath( final Path path ) {
381
    assert path != null;
382
    mPath = path;
383
384
    updateTab();
385
  }
386
387
  public boolean isModified() {
388
    return mModified.get();
389
  }
390
391
  ReadOnlyBooleanProperty modifiedProperty() {
392
    return mModified.getReadOnlyProperty();
393
  }
394
395
  BooleanProperty canUndoProperty() {
396
    return this.canUndo;
397
  }
398
399
  BooleanProperty canRedoProperty() {
400
    return this.canRedo;
401
  }
402
403
  private UndoManager<?> getUndoManager() {
404
    return getEditorPane().getUndoManager();
405
  }
406
407
  /**
408
   * Forwards to the editor pane's listeners for text change events.
409
   *
410
   * @param listener The listener to notify when the text changes.
411
   */
412
  public void addTextChangeListener( final ChangeListener<String> listener ) {
413
    getEditorPane().addTextChangeListener( listener );
414
  }
415
416
  /**
417
   * Forwards to the editor pane's listeners for caret change events.
418
   *
419
   * @param listener Notified when the caret position changes.
420
   */
421
  public void addCaretPositionListener(
422
      final ChangeListener<? super Integer> listener ) {
423
    getEditorPane().addCaretPositionListener( listener );
424
  }
425
426
  /**
427
   * Forwards to the editor pane's listeners for paragraph index change events.
428
   *
429
   * @param listener Notified when the caret's paragraph index changes.
430
   */
431
  public void addCaretParagraphListener(
432
      final ChangeListener<? super Integer> listener ) {
433
    getEditorPane().addCaretParagraphListener( listener );
30
import com.keenwrite.processors.Processor;
31
import com.keenwrite.processors.markdown.CaretPosition;
32
import com.keenwrite.service.events.Notification;
33
import com.keenwrite.service.events.Notifier;
34
import javafx.beans.binding.Bindings;
35
import javafx.beans.property.BooleanProperty;
36
import javafx.beans.property.ReadOnlyBooleanProperty;
37
import javafx.beans.property.ReadOnlyBooleanWrapper;
38
import javafx.beans.property.SimpleBooleanProperty;
39
import javafx.beans.value.ChangeListener;
40
import javafx.event.Event;
41
import javafx.event.EventHandler;
42
import javafx.event.EventType;
43
import javafx.scene.Scene;
44
import javafx.scene.control.Tab;
45
import javafx.scene.control.Tooltip;
46
import javafx.scene.text.Text;
47
import javafx.stage.Window;
48
import org.fxmisc.flowless.VirtualizedScrollPane;
49
import org.fxmisc.richtext.StyleClassedTextArea;
50
import org.fxmisc.undo.UndoManager;
51
import org.jetbrains.annotations.NotNull;
52
import org.mozilla.universalchardet.UniversalDetector;
53
54
import java.io.File;
55
import java.nio.charset.Charset;
56
import java.nio.file.Files;
57
import java.nio.file.Path;
58
59
import static com.keenwrite.Messages.get;
60
import static com.keenwrite.StatusBarNotifier.clue;
61
import static com.keenwrite.StatusBarNotifier.getNotifier;
62
import static java.nio.charset.StandardCharsets.UTF_8;
63
import static java.util.Locale.ENGLISH;
64
import static javafx.application.Platform.runLater;
65
66
/**
67
 * Editor for a single file.
68
 */
69
public final class FileEditorTab extends Tab {
70
71
  private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
72
73
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
74
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
75
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
76
77
  /**
78
   * Character encoding used by the file (or default encoding if none found).
79
   */
80
  private Charset mEncoding = UTF_8;
81
82
  /**
83
   * File to load into the editor.
84
   */
85
  private Path mPath;
86
87
  /**
88
   * Dynamically updated position of the caret within the text editor.
89
   */
90
  private final CaretPosition mCaretPosition;
91
92
  public FileEditorTab( final Path path ) {
93
    setPath( path );
94
95
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
96
97
    setOnSelectionChanged( e -> {
98
      if( isSelected() ) {
99
        runLater( this::activated );
100
        requestFocus();
101
      }
102
    } );
103
104
    mCaretPosition = createCaretPosition( getEditor() );
105
  }
106
107
  private CaretPosition createCaretPosition(
108
      final StyleClassedTextArea editor ) {
109
    final var propParaIndex = editor.currentParagraphProperty();
110
    final var propParagraphs = editor.getParagraphs();
111
    final var propParaOffset = editor.caretColumnProperty();
112
    final var propTextOffset = editor.caretPositionProperty();
113
114
    return CaretPosition
115
        .builder()
116
        .with( CaretPosition.Mutator::setParagraph, propParaIndex )
117
        .with( CaretPosition.Mutator::setParagraphs, propParagraphs )
118
        .with( CaretPosition.Mutator::setParaOffset, propParaOffset )
119
        .with( CaretPosition.Mutator::setTextOffset, propTextOffset )
120
        .build();
121
  }
122
123
  private void updateTab() {
124
    setText( getTabTitle() );
125
    setGraphic( getModifiedMark() );
126
    setTooltip( getTabTooltip() );
127
  }
128
129
  /**
130
   * Returns the base filename (without the directory names).
131
   *
132
   * @return The untitled text if the path hasn't been set.
133
   */
134
  private String getTabTitle() {
135
    return getPath().getFileName().toString();
136
  }
137
138
  /**
139
   * Returns the full filename represented by the path.
140
   *
141
   * @return The untitled text if the path hasn't been set.
142
   */
143
  private Tooltip getTabTooltip() {
144
    final Path filePath = getPath();
145
    return new Tooltip( filePath == null ? "" : filePath.toString() );
146
  }
147
148
  /**
149
   * Returns a marker to indicate whether the file has been modified.
150
   *
151
   * @return "*" when the file has changed; otherwise null.
152
   */
153
  private Text getModifiedMark() {
154
    return isModified() ? new Text( "*" ) : null;
155
  }
156
157
  /**
158
   * Called when the user switches tab.
159
   */
160
  private void activated() {
161
    // Tab is closed or no longer active.
162
    if( getTabPane() == null || !isSelected() ) {
163
      return;
164
    }
165
166
    // If the tab is devoid of content, load it.
167
    if( getContent() == null ) {
168
      readFile();
169
      initLayout();
170
      initUndoManager();
171
    }
172
  }
173
174
  private void initLayout() {
175
    setContent( getScrollPane() );
176
  }
177
178
  /**
179
   * Tracks undo requests, but can only be called <em>after</em> load.
180
   */
181
  private void initUndoManager() {
182
    final UndoManager<?> undoManager = getUndoManager();
183
    undoManager.forgetHistory();
184
185
    // Bind the editor undo manager to the properties.
186
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
187
    canUndo.bind( undoManager.undoAvailableProperty() );
188
    canRedo.bind( undoManager.redoAvailableProperty() );
189
  }
190
191
  private void requestFocus() {
192
    getEditorPane().requestFocus();
193
  }
194
195
  /**
196
   * Searches from the caret position forward for the given string.
197
   *
198
   * @param needle The text string to match.
199
   */
200
  public void searchNext( final String needle ) {
201
    final String haystack = getEditorText();
202
    int index = haystack.indexOf( needle, getCaretTextOffset() );
203
204
    // Wrap around.
205
    if( index == -1 ) {
206
      index = haystack.indexOf( needle );
207
    }
208
209
    if( index >= 0 ) {
210
      setCaretTextOffset( index );
211
      getEditor().selectRange( index, index + needle.length() );
212
    }
213
  }
214
215
  /**
216
   * Gets a reference to the scroll pane that houses the editor.
217
   *
218
   * @return The editor's scroll pane, containing a vertical scrollbar.
219
   */
220
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
221
    return getEditorPane().getScrollPane();
222
  }
223
224
  /**
225
   * Returns an instance of {@link CaretPosition} that contains information
226
   * about the caret, including the offset into the text, the paragraph into
227
   * the text, maximum number of paragraphs, and more. This allows the main
228
   * application and the {@link Processor} instances to get the current
229
   * caret position.
230
   *
231
   * @return The current values for the caret's position within the editor.
232
   */
233
  public CaretPosition getCaretPosition() {
234
    return mCaretPosition;
235
  }
236
237
  /**
238
   * Returns the index into the text where the caret blinks happily away.
239
   *
240
   * @return A number from 0 to the editor's document text length.
241
   */
242
  private int getCaretTextOffset() {
243
    return getEditor().getCaretPosition();
244
  }
245
246
  /**
247
   * Moves the caret to a given offset.
248
   *
249
   * @param offset The new caret offset.
250
   */
251
  private void setCaretTextOffset( final int offset ) {
252
    getEditor().moveTo( offset );
253
    getEditor().requestFollowCaret();
254
  }
255
256
  /**
257
   * Returns the text area associated with this tab.
258
   *
259
   * @return A text editor.
260
   */
261
  private StyleClassedTextArea getEditor() {
262
    return getEditorPane().getEditor();
263
  }
264
265
  /**
266
   * Returns true if the given path exactly matches this tab's path.
267
   *
268
   * @param check The path to compare against.
269
   * @return true The paths are the same.
270
   */
271
  public boolean isPath( final Path check ) {
272
    final Path filePath = getPath();
273
274
    return filePath != null && filePath.equals( check );
275
  }
276
277
  /**
278
   * Reads the entire file contents from the path associated with this tab.
279
   */
280
  private void readFile() {
281
    final Path path = getPath();
282
    final File file = path.toFile();
283
284
    try {
285
      if( file.exists() ) {
286
        if( file.canWrite() && file.canRead() ) {
287
          final EditorPane pane = getEditorPane();
288
          pane.setText( asString( Files.readAllBytes( path ) ) );
289
          pane.scrollToTop();
290
        }
291
        else {
292
          final String msg = get( "FileEditor.loadFailed.reason.permissions" );
293
          clue( "FileEditor.loadFailed.message", file.toString(), msg );
294
        }
295
      }
296
    } catch( final Exception ex ) {
297
      clue( ex );
298
    }
299
  }
300
301
  /**
302
   * Saves the entire file contents from the path associated with this tab.
303
   *
304
   * @return true The file has been saved.
305
   */
306
  public boolean save() {
307
    try {
308
      final EditorPane editor = getEditorPane();
309
      Files.write( getPath(), asBytes( editor.getText() ) );
310
      editor.getUndoManager().mark();
311
      return true;
312
    } catch( final Exception ex ) {
313
      return popupAlert(
314
          "FileEditor.saveFailed.title",
315
          "FileEditor.saveFailed.message",
316
          ex
317
      );
318
    }
319
  }
320
321
  /**
322
   * Creates an alert dialog and waits for it to close.
323
   *
324
   * @param titleKey   Resource bundle key for the alert dialog title.
325
   * @param messageKey Resource bundle key for the alert dialog message.
326
   * @param e          The unexpected happening.
327
   * @return false
328
   */
329
  @SuppressWarnings("SameParameterValue")
330
  private boolean popupAlert(
331
      final String titleKey, final String messageKey, final Exception e ) {
332
    final Notifier service = getNotifier();
333
    final Path filePath = getPath();
334
335
    final Notification message = service.createNotification(
336
        get( titleKey ),
337
        get( messageKey ),
338
        filePath == null ? "" : filePath,
339
        e.getMessage()
340
    );
341
342
    try {
343
      service.createError( getWindow(), message ).showAndWait();
344
    } catch( final Exception ex ) {
345
      clue( ex );
346
    }
347
348
    return false;
349
  }
350
351
  private Window getWindow() {
352
    final Scene scene = getEditorPane().getScene();
353
354
    if( scene == null ) {
355
      throw new UnsupportedOperationException( "No scene window available" );
356
    }
357
358
    return scene.getWindow();
359
  }
360
361
  /**
362
   * Returns a best guess at the file encoding. If the encoding could not be
363
   * detected, this will return the default charset for the JVM.
364
   *
365
   * @param bytes The bytes to perform character encoding detection.
366
   * @return The character encoding.
367
   */
368
  private Charset detectEncoding( final byte[] bytes ) {
369
    final var detector = new UniversalDetector( null );
370
    detector.handleData( bytes, 0, bytes.length );
371
    detector.dataEnd();
372
373
    final String charset = detector.getDetectedCharset();
374
375
    return charset == null
376
        ? Charset.defaultCharset()
377
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
378
  }
379
380
  /**
381
   * Converts the given string to an array of bytes using the encoding that was
382
   * originally detected (if any) and associated with this file.
383
   *
384
   * @param text The text to convert into the original file encoding.
385
   * @return A series of bytes ready for writing to a file.
386
   */
387
  private byte[] asBytes( final String text ) {
388
    return text.getBytes( getEncoding() );
389
  }
390
391
  /**
392
   * Converts the given bytes into a Java String. This will call setEncoding
393
   * with the encoding detected by the CharsetDetector.
394
   *
395
   * @param text The text of unknown character encoding.
396
   * @return The text, in its auto-detected encoding, as a String.
397
   */
398
  private String asString( final byte[] text ) {
399
    setEncoding( detectEncoding( text ) );
400
    return new String( text, getEncoding() );
401
  }
402
403
  /**
404
   * Returns the path to the file being edited in this tab.
405
   *
406
   * @return A non-null instance.
407
   */
408
  public Path getPath() {
409
    return mPath;
410
  }
411
412
  /**
413
   * Sets the path to a file for editing and then updates the tab with the
414
   * file contents.
415
   *
416
   * @param path A non-null instance.
417
   */
418
  public void setPath( final Path path ) {
419
    assert path != null;
420
    mPath = path;
421
422
    updateTab();
423
  }
424
425
  public boolean isModified() {
426
    return mModified.get();
427
  }
428
429
  ReadOnlyBooleanProperty modifiedProperty() {
430
    return mModified.getReadOnlyProperty();
431
  }
432
433
  BooleanProperty canUndoProperty() {
434
    return this.canUndo;
435
  }
436
437
  BooleanProperty canRedoProperty() {
438
    return this.canRedo;
439
  }
440
441
  private UndoManager<?> getUndoManager() {
442
    return getEditorPane().getUndoManager();
443
  }
444
445
  /**
446
   * Forwards to the editor pane's listeners for text change events.
447
   *
448
   * @param listener The listener to notify when the text changes.
449
   */
450
  public void addTextChangeListener( final ChangeListener<String> listener ) {
451
    getEditorPane().addTextChangeListener( listener );
452
  }
453
454
  /**
455
   * Forwards to the editor pane's listeners for caret change events.
456
   *
457
   * @param listener Notified when the caret position changes.
458
   */
459
  public void addCaretPositionListener(
460
      final ChangeListener<? super Integer> listener ) {
461
    getEditorPane().addCaretPositionListener( listener );
434462
  }
435463
M src/main/java/com/keenwrite/FileEditorTabPane.java
8383
      new ReadOnlyBooleanWrapper();
8484
  private final ChangeListener<Integer> mCaretPositionListener;
85
  private final ChangeListener<Integer> mCaretParagraphListener;
8685
8786
  /**
8887
   * Constructs a new file editor tab pane.
8988
   *
9089
   * @param caretPositionListener  Listens for changes to caret position so
9190
   *                               that the status bar can update.
92
   * @param caretParagraphListener Listens for changes to the caret's paragraph
93
   *                               so that scrolling may occur.
9491
   */
9592
  public FileEditorTabPane(
96
      final ChangeListener<Integer> caretPositionListener,
97
      final ChangeListener<Integer> caretParagraphListener ) {
93
      final ChangeListener<Integer> caretPositionListener ) {
9894
    final ObservableList<Tab> tabs = getTabs();
9995
...
146142
147143
    mCaretPositionListener = caretPositionListener;
148
    mCaretParagraphListener = caretParagraphListener;
149144
  }
150145
...
210205
211206
    tab.addCaretPositionListener( mCaretPositionListener );
212
    tab.addCaretParagraphListener( mCaretParagraphListener );
213207
214208
    return tab;
M src/main/java/com/keenwrite/FileType.java
8888
8989
  /**
90
   * Answers whether this file type belongs to the set of file types that have
91
   * embedded R statements.
92
   *
93
   * @return {@code true} when the file type is either R Markdown or R XML.
94
   */
95
  public boolean isR() {
96
    return this == RMARKDOWN || this == RXML;
97
  }
98
99
  /**
90100
   * Returns the human-readable name for the file type.
91101
   *
M src/main/java/com/keenwrite/MainWindow.java
3535
import com.keenwrite.definition.yaml.YamlDefinitionSource;
3636
import com.keenwrite.editors.DefinitionNameInjector;
37
import com.keenwrite.editors.EditorPane;
38
import com.keenwrite.editors.markdown.MarkdownEditorPane;
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()
37
import com.keenwrite.editors.markdown.MarkdownEditorPane;
38
import com.keenwrite.exceptions.MissingFileException;
39
import com.keenwrite.preferences.UserPreferences;
40
import com.keenwrite.preview.HTMLPreviewPane;
41
import com.keenwrite.processors.Processor;
42
import com.keenwrite.processors.ProcessorContext;
43
import com.keenwrite.processors.ProcessorFactory;
44
import com.keenwrite.processors.markdown.MarkdownProcessor;
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.ActionUtils;
53
import com.keenwrite.util.SeparatorAction;
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.KeyEvent;
74
import javafx.scene.layout.BorderPane;
75
import javafx.scene.layout.VBox;
76
import javafx.scene.text.Text;
77
import javafx.stage.FileChooser;
78
import javafx.stage.Window;
79
import javafx.stage.WindowEvent;
80
import javafx.util.Duration;
81
import org.apache.commons.lang3.SystemUtils;
82
import org.controlsfx.control.StatusBar;
83
import org.fxmisc.richtext.StyleClassedTextArea;
84
import org.fxmisc.richtext.model.StyleSpansBuilder;
85
import org.reactfx.value.Val;
86
87
import java.io.BufferedReader;
88
import java.io.File;
89
import java.io.IOException;
90
import java.io.InputStreamReader;
91
import java.nio.file.Path;
92
import java.util.*;
93
import java.util.concurrent.atomic.AtomicInteger;
94
import java.util.function.Consumer;
95
import java.util.function.Function;
96
import java.util.prefs.Preferences;
97
import java.util.stream.Collectors;
98
99
import static com.keenwrite.Bootstrap.APP_TITLE;
100
import static com.keenwrite.Constants.*;
101
import static com.keenwrite.ExportFormat.*;
102
import static com.keenwrite.Messages.get;
103
import static com.keenwrite.StatusBarNotifier.clue;
104
import static com.keenwrite.processors.ProcessorFactory.processChain;
105
import static com.keenwrite.util.StageState.*;
106
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
107
import static java.nio.charset.StandardCharsets.UTF_8;
108
import static java.nio.file.Files.writeString;
109
import static java.util.Collections.emptyList;
110
import static java.util.Collections.singleton;
111
import static javafx.application.Platform.runLater;
112
import static javafx.event.Event.fireEvent;
113
import static javafx.scene.control.Alert.AlertType.INFORMATION;
114
import static javafx.scene.input.KeyCode.ENTER;
115
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
116
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
117
118
/**
119
 * Main window containing a tab pane in the center for file editors.
120
 */
121
public class MainWindow implements Observer {
122
  /**
123
   * The {@code OPTIONS} variable must be declared before all other variables
124
   * to prevent subsequent initializations from failing due to missing user
125
   * preferences.
126
   */
127
  private static final Options sOptions = Services.load( Options.class );
128
  private static final Snitch SNITCH = Services.load( Snitch.class );
129
130
  private final Scene mScene;
131
  private final StatusBar mStatusBar;
132
  private final Text mLineNumberText;
133
  private final TextField mFindTextField;
134
  private final SpellChecker mSpellChecker;
135
136
  private final Object mMutex = new Object();
137
138
  /**
139
   * Prevents re-instantiation of processing classes.
140
   */
141
  private final Map<FileEditorTab, Processor<String>> mProcessors =
142
      new HashMap<>();
143
144
  private final Map<String, String> mResolvedMap =
145
      new HashMap<>( DEFAULT_MAP_SIZE );
146
147
  private final EventHandler<PreferencesFxEvent> mRPreferencesListener =
148
      event -> rerender();
149
150
  /**
151
   * Called when the definition data is changed.
152
   */
153
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
154
      mTreeHandler = event -> {
155
    exportDefinitions( getDefinitionPath() );
156
    interpolateResolvedMap();
157
    rerender();
158
  };
159
160
  /**
161
   * Called to inject the selected item when the user presses ENTER in the
162
   * definition pane.
163
   */
164
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
165
      event -> {
166
        if( event.getCode() == ENTER ) {
167
          getDefinitionNameInjector().injectSelectedItem();
168
        }
169
      };
170
171
  private final ChangeListener<Integer> mCaretPositionListener =
172
      ( observable, oldPosition, newPosition ) -> {
173
        processActiveTab();
174
      };
175
176
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
177
  private final DefinitionPane mDefinitionPane = createDefinitionPane();
178
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
179
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
180
      mCaretPositionListener );
181
182
  /**
183
   * Listens on the definition pane for double-click events.
184
   */
185
  private final DefinitionNameInjector mDefinitionNameInjector
186
      = new DefinitionNameInjector( mDefinitionPane );
187
188
  public MainWindow() {
189
    mStatusBar = createStatusBar();
190
    mLineNumberText = createLineNumberText();
191
    mFindTextField = createFindTextField();
192
    mScene = createScene();
193
    mSpellChecker = createSpellChecker();
194
195
    // Add the close request listener before the window is shown.
196
    initLayout();
197
    StatusBarNotifier.setStatusBar( mStatusBar );
198
  }
199
200
  /**
201
   * Called after the stage is shown.
202
   */
203
  public void init() {
204
    initFindInput();
205
    initSnitch();
206
    initDefinitionListener();
207
    initTabAddedListener();
208
    initTabChangedListener();
209
    initPreferences();
210
    initVariableNameInjector();
211
  }
212
213
  private void initLayout() {
214
    final var scene = getScene();
215
216
    scene.getStylesheets().add( STYLESHEET_SCENE );
217
    scene.windowProperty().addListener(
218
        ( unused, oldWindow, newWindow ) ->
219
            newWindow.setOnCloseRequest(
220
                e -> {
221
                  if( !getFileEditorPane().closeAllEditors() ) {
222
                    e.consume();
223
                  }
224
                }
225
            )
226
    );
227
  }
228
229
  /**
230
   * Initialize the find input text field to listen on F3, ENTER, and
231
   * ESCAPE key presses.
232
   */
233
  private void initFindInput() {
234
    final TextField input = getFindTextField();
235
236
    input.setOnKeyPressed( ( KeyEvent event ) -> {
237
      switch( event.getCode() ) {
238
        case F3:
239
        case ENTER:
240
          editFindNext();
241
          break;
242
        case F:
243
          if( !event.isControlDown() ) {
244
            break;
245
          }
246
        case ESCAPE:
247
          getStatusBar().setGraphic( null );
248
          getActiveFileEditorTab().getEditorPane().requestFocus();
249
          break;
250
      }
251
    } );
252
253
    // Remove when the input field loses focus.
254
    input.focusedProperty().addListener(
255
        ( focused, oldFocus, newFocus ) -> {
256
          if( !newFocus ) {
257
            getStatusBar().setGraphic( null );
258
          }
259
        }
260
    );
261
  }
262
263
  /**
264
   * Watch for changes to external files. In particular, this awaits
265
   * modifications to any XSL files associated with XML files being edited.
266
   * When
267
   * an XSL file is modified (external to the application), the snitch's ears
268
   * perk up and the file is reloaded. This keeps the XSL transformation up to
269
   * date with what's on the file system.
270
   */
271
  private void initSnitch() {
272
    SNITCH.addObserver( this );
273
  }
274
275
  /**
276
   * Listen for {@link FileEditorTabPane} to receive open definition file
277
   * event.
278
   */
279
  private void initDefinitionListener() {
280
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
281
        ( final ObservableValue<? extends Path> file,
282
          final Path oldPath, final Path newPath ) -> {
283
          openDefinitions( newPath );
284
          rerender();
285
        }
286
    );
287
  }
288
289
  /**
290
   * Re-instantiates all processors then re-renders the active tab. This
291
   * will refresh the resolved map, force R to re-initialize, and brute-force
292
   * XSLT file reloads.
293
   */
294
  private void rerender() {
295
    runLater(
296
        () -> {
297
          resetProcessors();
298
          processActiveTab();
299
        }
300
    );
301
  }
302
303
  /**
304
   * When tabs are added, hook the various change listeners onto the new
305
   * tab sothat the preview pane refreshes as necessary.
306
   */
307
  private void initTabAddedListener() {
308
    final FileEditorTabPane editorPane = getFileEditorPane();
309
310
    // Make sure the text processor kicks off when new files are opened.
311
    final ObservableList<Tab> tabs = editorPane.getTabs();
312
313
    // Update the preview pane on tab changes.
314
    tabs.addListener(
315
        ( final Change<? extends Tab> change ) -> {
316
          while( change.next() ) {
317
            if( change.wasAdded() ) {
318
              // Multiple tabs can be added simultaneously.
319
              for( final Tab newTab : change.getAddedSubList() ) {
320
                final FileEditorTab tab = (FileEditorTab) newTab;
321
322
                initTextChangeListener( tab );
323
                initScrollEventListener( tab );
324
                initSpellCheckListener( tab );
325
//              initSyntaxListener( tab );
326
              }
327
            }
328
          }
329
        }
330
    );
331
  }
332
333
  private void initTextChangeListener( final FileEditorTab tab ) {
334
    tab.addTextChangeListener(
335
        ( __, ov, nv ) -> {
336
          process( tab );
337
        }
338
    );
339
  }
340
341
  private void initScrollEventListener( final FileEditorTab tab ) {
342
    final var scrollPane = tab.getScrollPane();
343
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
344
345
    addShowListener( scrollPane, ( __ ) -> {
346
      final var handler = new ScrollEventHandler( scrollPane, scrollBar );
347
      handler.enabledProperty().bind( tab.selectedProperty() );
348
    } );
349
  }
350
351
  /**
352
   * Listen for changes to the any particular paragraph and perform a quick
353
   * spell check upon it. The style classes in the editor will be changed to
354
   * mark any spelling mistakes in the paragraph. The user may then interact
355
   * with any misspelled word (i.e., any piece of text that is marked) to
356
   * revise the spelling.
357
   *
358
   * @param tab The tab to spellcheck.
359
   */
360
  private void initSpellCheckListener( final FileEditorTab tab ) {
361
    final var editor = tab.getEditorPane().getEditor();
362
363
    // When the editor first appears, run a full spell check. This allows
364
    // spell checking while typing to be restricted to the active paragraph,
365
    // which is usually substantially smaller than the whole document.
366
    addShowListener(
367
        editor, ( __ ) -> spellcheck( editor, editor.getText() )
368
    );
369
370
    // Use the plain text changes so that notifications of style changes
371
    // are suppressed. Checking against the identity ensures that only
372
    // new text additions or deletions trigger proofreading.
373
    editor.plainTextChanges()
374
          .filter( p -> !p.isIdentity() ).subscribe( change -> {
375
376
      // Only perform a spell check on the current paragraph. The
377
      // entire document is processed once, when opened.
378
      final var offset = change.getPosition();
379
      final var position = editor.offsetToPosition( offset, Forward );
380
      final var paraId = position.getMajor();
381
      final var paragraph = editor.getParagraph( paraId );
382
      final var text = paragraph.getText();
383
384
      // Ensure that styles aren't doubled-up.
385
      editor.clearStyle( paraId );
386
387
      spellcheck( editor, text, paraId );
388
    } );
389
  }
390
391
  /**
392
   * Listen for new tab selection events.
393
   */
394
  private void initTabChangedListener() {
395
    final FileEditorTabPane editorPane = getFileEditorPane();
396
397
    // Update the preview pane changing tabs.
398
    editorPane.addTabSelectionListener(
399
        ( __, oldTab, newTab ) -> {
400
          if( newTab == null ) {
401
            // Clear the preview pane when closing an editor. When the last
402
            // tab is closed, this ensures that the preview pane is empty.
403
            getPreviewPane().clear();
404
          }
405
          else {
406
            final var tab = (FileEditorTab) newTab;
407
            updateVariableNameInjector( tab );
408
            process( tab );
409
          }
410
        }
411
    );
412
  }
413
414
  /**
415
   * Reloads the preferences from the previous session.
416
   */
417
  private void initPreferences() {
418
    initDefinitionPane();
419
    getFileEditorPane().initPreferences();
420
    getUserPreferences().addSaveEventHandler( mRPreferencesListener );
421
  }
422
423
  private void initVariableNameInjector() {
424
    updateVariableNameInjector( getActiveFileEditorTab() );
425
  }
426
427
  /**
428
   * Calls the listener when the given node is shown for the first time. The
429
   * visible property is not the same as the initial showing event; visibility
430
   * can be triggered numerous times (such as going off screen).
431
   * <p>
432
   * This is called, for example, before the drag handler can be attached,
433
   * because the scrollbar for the text editor pane must be visible.
434
   * </p>
435
   *
436
   * @param node     The node to watch for showing.
437
   * @param consumer The consumer to invoke when the event fires.
438
   */
439
  private void addShowListener(
440
      final Node node, final Consumer<Void> consumer ) {
441
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
442
        runLater( () -> {
443
          if( newShow != null && newShow ) {
444
            try {
445
              consumer.accept( null );
446
            } catch( final Exception ex ) {
447
              clue( ex );
448
            }
449
          }
450
        } );
451
452
    Val.flatMap( node.sceneProperty(), Scene::windowProperty )
453
       .flatMap( Window::showingProperty )
454
       .addListener( listener );
455
  }
456
457
  private void scrollToCaret() {
458
    synchronized( mMutex ) {
459
      final var previewPane = getPreviewPane();
460
461
      previewPane.scrollTo( CARET_ID );
462
      previewPane.repaintScrollPane();
463
    }
464
  }
465
466
  private void updateVariableNameInjector( final FileEditorTab tab ) {
467
    getDefinitionNameInjector().addListener( tab );
468
  }
469
470
  /**
471
   * Called to update the status bar's caret position when a new tab is added
472
   * or the active tab is switched.
473
   *
474
   * @param tab The active tab containing a caret position to show.
475
   */
476
  private void updateCaretStatus( final FileEditorTab tab ) {
477
    getLineNumberText().setText( tab.getCaretPosition().toString() );
478
  }
479
480
  /**
481
   * Called whenever the preview pane becomes out of sync with the file editor
482
   * tab. This can be called when the text changes, the caret paragraph
483
   * changes, or the file tab changes.
484
   *
485
   * @param tab The file editor tab that has been changed in some fashion.
486
   */
487
  private void process( final FileEditorTab tab ) {
488
    if( tab != null ) {
489
      getPreviewPane().setPath( tab.getPath() );
490
491
      final Processor<String> processor = getProcessors().computeIfAbsent(
492
          tab, p -> createProcessors( tab )
493
      );
494
495
      try {
496
        updateCaretStatus( tab );
497
        processChain( processor, tab.getEditorText() );
498
        scrollToCaret();
499
      } catch( final Exception ex ) {
500
        clue( ex );
501
      }
502
    }
503
  }
504
505
  private void processActiveTab() {
506
    process( getActiveFileEditorTab() );
507
  }
508
509
  /**
510
   * Called when a definition source is opened.
511
   *
512
   * @param path Path to the definition source that was opened.
513
   */
514
  private void openDefinitions( final Path path ) {
515
    try {
516
      final var ds = createDefinitionSource( path );
517
      setDefinitionSource( ds );
518
519
      final var prefs = getUserPreferences();
520
      prefs.definitionPathProperty().setValue( path.toFile() );
521
      prefs.save();
522
523
      final var tooltipPath = new Tooltip( path.toString() );
524
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
525
526
      final var pane = getDefinitionPane();
527
      pane.update( ds );
528
      pane.addTreeChangeHandler( mTreeHandler );
529
      pane.addKeyEventHandler( mDefinitionKeyHandler );
530
      pane.filenameProperty().setValue( path.getFileName().toString() );
531
      pane.setTooltip( tooltipPath );
532
533
      interpolateResolvedMap();
534
    } catch( final Exception ex ) {
535
      clue( ex );
536
    }
537
  }
538
539
  private void exportDefinitions( final Path path ) {
540
    try {
541
      final var pane = getDefinitionPane();
542
      final var root = pane.getTreeView().getRoot();
543
      final var problemChild = pane.isTreeWellFormed();
544
545
      if( problemChild == null ) {
546
        getDefinitionSource().getTreeAdapter().export( root, path );
547
      }
548
      else {
549
        clue( "yaml.error.tree.form", problemChild.getValue() );
550
      }
551
    } catch( final Exception ex ) {
552
      clue( ex );
553
    }
554
  }
555
556
  private void interpolateResolvedMap() {
557
    final var treeMap = getDefinitionPane().toMap();
558
    final var map = new HashMap<>( treeMap );
559
    MapInterpolator.interpolate( map );
560
561
    getResolvedMap().clear();
562
    getResolvedMap().putAll( map );
563
  }
564
565
  private void initDefinitionPane() {
566
    openDefinitions( getDefinitionPath() );
567
  }
568
569
  //---- File actions -------------------------------------------------------
570
571
  /**
572
   * Called when an {@link Observable} instance has changed. This is called
573
   * by both the {@link Snitch} service and the notify service. The @link
574
   * Snitch} service can be called for different file types, including
575
   * {@link DefinitionSource} instances.
576
   *
577
   * @param observable The observed instance.
578
   * @param value      The noteworthy item.
579
   */
580
  @Override
581
  public void update( final Observable observable, final Object value ) {
582
    if( value instanceof Path && observable instanceof Snitch ) {
583
      updateSelectedTab();
584
    }
585
  }
586
587
  /**
588
   * Called when a file has been modified.
589
   */
590
  private void updateSelectedTab() {
591
    rerender();
592
  }
593
594
  /**
595
   * After resetting the processors, they will refresh anew to be up-to-date
596
   * with the files (text and definition) currently loaded into the editor.
597
   */
598
  private void resetProcessors() {
599
    getProcessors().clear();
600
  }
601
602
  //---- File actions -------------------------------------------------------
603
604
  private void fileNew() {
605
    getFileEditorPane().newEditor();
606
  }
607
608
  private void fileOpen() {
609
    getFileEditorPane().openFileDialog();
610
  }
611
612
  private void fileClose() {
613
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
614
  }
615
616
  /**
617
   * TODO: Upon closing, first remove the tab change listeners. (There's no
618
   * need to re-render each tab when all are being closed.)
619
   */
620
  private void fileCloseAll() {
621
    getFileEditorPane().closeAllEditors();
622
  }
623
624
  private void fileSave() {
625
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
626
  }
627
628
  private void fileSaveAs() {
629
    final FileEditorTab editor = getActiveFileEditorTab();
630
    getFileEditorPane().saveEditorAs( editor );
631
    getProcessors().remove( editor );
632
633
    try {
634
      process( editor );
635
    } catch( final Exception ex ) {
636
      clue( ex );
637
    }
638
  }
639
640
  private void fileSaveAll() {
641
    getFileEditorPane().saveAllEditors();
642
  }
643
644
  /**
645
   * Exports the contents of the current tab according to the given
646
   * {@link ExportFormat}.
647
   *
648
   * @param format Configures the {@link MarkdownProcessor} when exporting.
649
   */
650
  private void fileExport( final ExportFormat format ) {
651
    final var tab = getActiveFileEditorTab();
652
    final var context = createProcessorContext( tab, format );
653
    final var chain = ProcessorFactory.createProcessors( context );
654
    final var doc = tab.getEditorText();
655
    final var export = processChain( chain, doc );
656
657
    final var filename = format.toExportFilename( tab.getPath().toFile() );
658
    final var dir = getPreferences().get( "lastDirectory", null );
659
    final var lastDir = new File( dir == null ? "." : dir );
660
661
    final FileChooser chooser = new FileChooser();
662
    chooser.setTitle( get( "Dialog.file.choose.export.title" ) );
663
    chooser.setInitialFileName( filename.getName() );
664
    chooser.setInitialDirectory( lastDir );
665
666
    final File file = chooser.showSaveDialog( getWindow() );
667
668
    if( file != null ) {
669
      try {
670
        writeString( file.toPath(), export, UTF_8 );
671
        final var m = get( "Main.status.export.success", file.toString() );
672
        clue( m );
673
      } catch( final IOException e ) {
674
        clue( e );
675
      }
676
    }
677
  }
678
679
  private void fileExit() {
680
    final Window window = getWindow();
681
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
682
  }
683
684
  //---- Edit actions -------------------------------------------------------
685
686
  /**
687
   * Used to find text in the active file editor window.
688
   */
689
  private void editFind() {
690
    final TextField input = getFindTextField();
691
    getStatusBar().setGraphic( input );
692
    input.requestFocus();
693
  }
694
695
  public void editFindNext() {
696
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
697
  }
698
699
  public void editPreferences() {
700
    getUserPreferences().show();
701
  }
702
703
  //---- Insert actions -----------------------------------------------------
704
705
  /**
706
   * Delegates to the active editor to handle wrapping the current text
707
   * selection with leading and trailing strings.
708
   *
709
   * @param leading  The string to put before the selection.
710
   * @param trailing The string to put after the selection.
711
   */
712
  private void insertMarkdown(
713
      final String leading, final String trailing ) {
714
    getActiveEditorPane().surroundSelection( leading, trailing );
715
  }
716
717
  private void insertMarkdown(
718
      final String leading, final String trailing, final String hint ) {
719
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
720
  }
721
722
  //---- Help actions -------------------------------------------------------
723
724
  private void helpAbout() {
725
    final Alert alert = new Alert( INFORMATION );
726
    alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
727
    alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
728
    alert.setContentText( get( "Dialog.about.content" ) );
729
    alert.setGraphic( new ImageView( ICON_DIALOG ) );
730
    alert.initOwner( getWindow() );
731
732
    alert.showAndWait();
733
  }
734
735
  //---- Member creators ----------------------------------------------------
736
737
  private SpellChecker createSpellChecker() {
738
    try {
739
      final Collection<String> lexicon = readLexicon( "en.txt" );
740
      return SymSpellSpeller.forLexicon( lexicon );
741
    } catch( final Exception ex ) {
742
      clue( ex );
743
      return new PermissiveSpeller();
744
    }
745
  }
746
747
  /**
748
   * Creates processors suited to parsing and rendering different file types.
749
   *
750
   * @param tab The tab that is subjected to processing.
751
   * @return A processor suited to the file type specified by the tab's path.
752
   */
753
  private Processor<String> createProcessors( final FileEditorTab tab ) {
754
    final var context = createProcessorContext( tab );
755
    return ProcessorFactory.createProcessors( context );
756
  }
757
758
  private ProcessorContext createProcessorContext(
759
      final FileEditorTab tab, final ExportFormat format ) {
760
    final var pane = getPreviewPane();
761
    final var map = getResolvedMap();
762
    return new ProcessorContext( pane, map, tab, format );
763
  }
764
765
  private ProcessorContext createProcessorContext( final FileEditorTab tab ) {
766
    return createProcessorContext( tab, NONE );
767
  }
768
769
  private DefinitionPane createDefinitionPane() {
770
    return new DefinitionPane();
771
  }
772
773
  private HTMLPreviewPane createHTMLPreviewPane() {
774
    return new HTMLPreviewPane();
775
  }
776
777
  private DefinitionSource createDefaultDefinitionSource() {
778
    return new YamlDefinitionSource( getDefinitionPath() );
779
  }
780
781
  private DefinitionSource createDefinitionSource( final Path path ) {
782
    try {
783
      return createDefinitionFactory().createDefinitionSource( path );
784
    } catch( final Exception ex ) {
785
      clue( ex );
786
      return createDefaultDefinitionSource();
787
    }
788
  }
789
790
  private TextField createFindTextField() {
791
    return new TextField();
792
  }
793
794
  private DefinitionFactory createDefinitionFactory() {
795
    return new DefinitionFactory();
796
  }
797
798
  private StatusBar createStatusBar() {
799
    return new StatusBar();
800
  }
801
802
  private Scene createScene() {
803
    final SplitPane splitPane = new SplitPane(
804
        getDefinitionPane(),
805
        getFileEditorPane(),
806
        getPreviewPane() );
807
808
    splitPane.setDividerPositions(
809
        getFloat( K_PANE_SPLIT_DEFINITION, .22f ),
810
        getFloat( K_PANE_SPLIT_EDITOR, .60f ),
811
        getFloat( K_PANE_SPLIT_PREVIEW, .18f ) );
812
813
    getDefinitionPane().prefHeightProperty()
814
                       .bind( splitPane.heightProperty() );
815
816
    final BorderPane borderPane = new BorderPane();
817
    borderPane.setPrefSize( 1280, 800 );
818
    borderPane.setTop( createMenuBar() );
819
    borderPane.setBottom( getStatusBar() );
820
    borderPane.setCenter( splitPane );
821
822
    final VBox statusBar = new VBox();
823
    statusBar.setAlignment( Pos.BASELINE_CENTER );
824
    statusBar.getChildren().add( getLineNumberText() );
825
    getStatusBar().getRightItems().add( statusBar );
826
827
    // Force preview pane refresh on Windows.
828
    if( SystemUtils.IS_OS_WINDOWS ) {
829
      splitPane.getDividers().get( 1 ).positionProperty().addListener(
830
          ( l, oValue, nValue ) -> runLater(
831
              () -> getPreviewPane().repaintScrollPane()
832
          )
833
      );
834
    }
835
836
    return new Scene( borderPane );
837
  }
838
839
  private Text createLineNumberText() {
840
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
841
  }
842
843
  private Node createMenuBar() {
844
    final BooleanBinding activeFileEditorIsNull =
845
        getFileEditorPane().activeFileEditorProperty().isNull();
846
847
    // File actions
848
    final Action fileNewAction = Action
849
        .builder()
850
        .setText( "Main.menu.file.new" )
851
        .setAccelerator( "Shortcut+N" )
852
        .setIcon( FILE_ALT )
853
        .setAction( e -> fileNew() )
854
        .build();
855
    final Action fileOpenAction = Action
856
        .builder()
857
        .setText( "Main.menu.file.open" )
858
        .setAccelerator( "Shortcut+O" )
859
        .setIcon( FOLDER_OPEN_ALT )
860
        .setAction( e -> fileOpen() )
861
        .build();
862
    final Action fileCloseAction = Action
863
        .builder()
864
        .setText( "Main.menu.file.close" )
865
        .setAccelerator( "Shortcut+W" )
866
        .setAction( e -> fileClose() )
867
        .setDisabled( activeFileEditorIsNull )
868
        .build();
869
    final Action fileCloseAllAction = Action
870
        .builder()
871
        .setText( "Main.menu.file.close_all" )
872
        .setAction( e -> fileCloseAll() )
873
        .setDisabled( activeFileEditorIsNull )
874
        .build();
875
    final Action fileSaveAction = Action
876
        .builder()
877
        .setText( "Main.menu.file.save" )
878
        .setAccelerator( "Shortcut+S" )
879
        .setIcon( FLOPPY_ALT )
880
        .setAction( e -> fileSave() )
881
        .setDisabled( createActiveBooleanProperty(
882
            FileEditorTab::modifiedProperty ).not() )
883
        .build();
884
    final Action fileSaveAsAction = Action
885
        .builder()
886
        .setText( "Main.menu.file.save_as" )
887
        .setAction( e -> fileSaveAs() )
888
        .setDisabled( activeFileEditorIsNull )
889
        .build();
890
    final Action fileSaveAllAction = Action
891
        .builder()
892
        .setText( "Main.menu.file.save_all" )
893
        .setAccelerator( "Shortcut+Shift+S" )
894
        .setAction( e -> fileSaveAll() )
895
        .setDisabled( Bindings.not(
896
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
897
        .build();
898
    final Action fileExportAction = Action
899
        .builder()
900
        .setText( "Main.menu.file.export" )
901
        .build();
902
    final Action fileExportHtmlSvgAction = Action
903
        .builder()
904
        .setText( "Main.menu.file.export.html_svg" )
905
        .setAction( e -> fileExport( HTML_TEX_SVG ) )
906
        .build();
907
    final Action fileExportHtmlTexAction = Action
908
        .builder()
909
        .setText( "Main.menu.file.export.html_tex" )
910
        .setAction( e -> fileExport( HTML_TEX_DELIMITED ) )
911
        .build();
912
    final Action fileExportMarkdownAction = Action
913
        .builder()
914
        .setText( "Main.menu.file.export.markdown" )
915
        .setAction( e -> fileExport( MARKDOWN_PLAIN ) )
916
        .build();
917
    fileExportAction.addSubActions(
918
        fileExportHtmlSvgAction,
919
        fileExportHtmlTexAction,
920
        fileExportMarkdownAction );
921
922
    final Action fileExitAction = Action
923
        .builder()
924
        .setText( "Main.menu.file.exit" )
925
        .setAction( e -> fileExit() )
926
        .build();
927
928
    // Edit actions
929
    final Action editUndoAction = Action
930
        .builder()
931
        .setText( "Main.menu.edit.undo" )
932
        .setAccelerator( "Shortcut+Z" )
933
        .setIcon( UNDO )
934
        .setAction( e -> getActiveEditorPane().undo() )
935
        .setDisabled( createActiveBooleanProperty(
936
            FileEditorTab::canUndoProperty ).not() )
937
        .build();
938
    final Action editRedoAction = Action
939
        .builder()
940
        .setText( "Main.menu.edit.redo" )
941
        .setAccelerator( "Shortcut+Y" )
942
        .setIcon( REPEAT )
943
        .setAction( e -> getActiveEditorPane().redo() )
944
        .setDisabled( createActiveBooleanProperty(
945
            FileEditorTab::canRedoProperty ).not() )
946
        .build();
947
948
    final Action editCutAction = Action
949
        .builder()
950
        .setText( "Main.menu.edit.cut" )
951
        .setAccelerator( "Shortcut+X" )
952
        .setIcon( CUT )
953
        .setAction( e -> getActiveEditorPane().cut() )
954
        .setDisabled( activeFileEditorIsNull )
955
        .build();
956
    final Action editCopyAction = Action
957
        .builder()
958
        .setText( "Main.menu.edit.copy" )
959
        .setAccelerator( "Shortcut+C" )
960
        .setIcon( COPY )
961
        .setAction( e -> getActiveEditorPane().copy() )
962
        .setDisabled( activeFileEditorIsNull )
963
        .build();
964
    final Action editPasteAction = Action
965
        .builder()
966
        .setText( "Main.menu.edit.paste" )
967
        .setAccelerator( "Shortcut+V" )
968
        .setIcon( PASTE )
969
        .setAction( e -> getActiveEditorPane().paste() )
970
        .setDisabled( activeFileEditorIsNull )
971
        .build();
972
    final Action editSelectAllAction = Action
973
        .builder()
974
        .setText( "Main.menu.edit.selectAll" )
975
        .setAccelerator( "Shortcut+A" )
976
        .setAction( e -> getActiveEditorPane().selectAll() )
977
        .setDisabled( activeFileEditorIsNull )
978
        .build();
979
980
    final Action editFindAction = Action
981
        .builder()
982
        .setText( "Main.menu.edit.find" )
983
        .setAccelerator( "Ctrl+F" )
984
        .setIcon( SEARCH )
985
        .setAction( e -> editFind() )
986
        .setDisabled( activeFileEditorIsNull )
987
        .build();
988
    final Action editFindNextAction = Action
989
        .builder()
990
        .setText( "Main.menu.edit.find.next" )
991
        .setAccelerator( "F3" )
992
        .setAction( e -> editFindNext() )
993
        .setDisabled( activeFileEditorIsNull )
994
        .build();
995
    final Action editPreferencesAction = Action
996
        .builder()
997
        .setText( "Main.menu.edit.preferences" )
998
        .setAccelerator( "Ctrl+Alt+S" )
999
        .setAction( e -> editPreferences() )
1000
        .build();
1001
1002
    // Format actions
1003
    final Action formatBoldAction = Action
1004
        .builder()
1005
        .setText( "Main.menu.format.bold" )
1006
        .setAccelerator( "Shortcut+B" )
1007
        .setIcon( BOLD )
1008
        .setAction( e -> insertMarkdown( "**", "**" ) )
1009
        .setDisabled( activeFileEditorIsNull )
1010
        .build();
1011
    final Action formatItalicAction = Action
1012
        .builder()
1013
        .setText( "Main.menu.format.italic" )
1014
        .setAccelerator( "Shortcut+I" )
1015
        .setIcon( ITALIC )
1016
        .setAction( e -> insertMarkdown( "*", "*" ) )
1017
        .setDisabled( activeFileEditorIsNull )
1018
        .build();
1019
    final Action formatSuperscriptAction = Action
1020
        .builder()
1021
        .setText( "Main.menu.format.superscript" )
1022
        .setAccelerator( "Shortcut+[" )
1023
        .setIcon( SUPERSCRIPT )
1024
        .setAction( e -> insertMarkdown( "^", "^" ) )
1025
        .setDisabled( activeFileEditorIsNull )
1026
        .build();
1027
    final Action formatSubscriptAction = Action
1028
        .builder()
1029
        .setText( "Main.menu.format.subscript" )
1030
        .setAccelerator( "Shortcut+]" )
1031
        .setIcon( SUBSCRIPT )
1032
        .setAction( e -> insertMarkdown( "~", "~" ) )
1033
        .setDisabled( activeFileEditorIsNull )
1034
        .build();
1035
    final Action formatStrikethroughAction = Action
1036
        .builder()
1037
        .setText( "Main.menu.format.strikethrough" )
1038
        .setAccelerator( "Shortcut+T" )
1039
        .setIcon( STRIKETHROUGH )
1040
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
1041
        .setDisabled( activeFileEditorIsNull )
1042
        .build();
1043
1044
    // Insert actions
1045
    final Action insertBlockquoteAction = Action
1046
        .builder()
1047
        .setText( "Main.menu.insert.blockquote" )
1048
        .setAccelerator( "Ctrl+Q" )
1049
        .setIcon( QUOTE_LEFT )
1050
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
1051
        .setDisabled( activeFileEditorIsNull )
1052
        .build();
1053
    final Action insertCodeAction = Action
1054
        .builder()
1055
        .setText( "Main.menu.insert.code" )
1056
        .setAccelerator( "Shortcut+K" )
1057
        .setIcon( CODE )
1058
        .setAction( e -> insertMarkdown( "`", "`" ) )
1059
        .setDisabled( activeFileEditorIsNull )
1060
        .build();
1061
    final Action insertFencedCodeBlockAction = Action
1062
        .builder()
1063
        .setText( "Main.menu.insert.fenced_code_block" )
1064
        .setAccelerator( "Shortcut+Shift+K" )
1065
        .setIcon( FILE_CODE_ALT )
1066
        .setAction( e -> insertMarkdown(
1067
            "\n\n```\n",
1068
            "\n```\n\n",
1069
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
1070
        .setDisabled( activeFileEditorIsNull )
1071
        .build();
1072
    final Action insertLinkAction = Action
1073
        .builder()
1074
        .setText( "Main.menu.insert.link" )
1075
        .setAccelerator( "Shortcut+L" )
1076
        .setIcon( LINK )
1077
        .setAction( e -> getActiveEditorPane().insertLink() )
1078
        .setDisabled( activeFileEditorIsNull )
1079
        .build();
1080
    final Action insertImageAction = Action
1081
        .builder()
1082
        .setText( "Main.menu.insert.image" )
1083
        .setAccelerator( "Shortcut+G" )
1084
        .setIcon( PICTURE_ALT )
1085
        .setAction( e -> getActiveEditorPane().insertImage() )
1086
        .setDisabled( activeFileEditorIsNull )
1087
        .build();
1088
1089
    // Number of heading actions (H1 ... H3)
1090
    final int HEADINGS = 3;
1091
    final Action[] headings = new Action[ HEADINGS ];
1092
1093
    for( int i = 1; i <= HEADINGS; i++ ) {
1094
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
1095
      final String markup = String.format( "%n%n%s ", hashes );
1096
      final String text = "Main.menu.insert.heading." + i;
1097
      final String accelerator = "Shortcut+" + i;
1098
      final String prompt = text + ".prompt";
1099
1100
      headings[ i - 1 ] = Action
1101
          .builder()
1102
          .setText( text )
1103
          .setAccelerator( accelerator )
1104
          .setIcon( HEADER )
1105
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
1106
          .setDisabled( activeFileEditorIsNull )
1107
          .build();
1108
    }
1109
1110
    final Action insertUnorderedListAction = Action
1111
        .builder()
1112
        .setText( "Main.menu.insert.unordered_list" )
1113
        .setAccelerator( "Shortcut+U" )
1114
        .setIcon( LIST_UL )
1115
        .setAction( e -> insertMarkdown( "\n\n* ", "" ) )
1116
        .setDisabled( activeFileEditorIsNull )
1117
        .build();
1118
    final Action insertOrderedListAction = Action
1119
        .builder()
1120
        .setText( "Main.menu.insert.ordered_list" )
1121
        .setAccelerator( "Shortcut+Shift+O" )
1122
        .setIcon( LIST_OL )
1123
        .setAction( e -> insertMarkdown(
1124
            "\n\n1. ", "" ) )
1125
        .setDisabled( activeFileEditorIsNull )
1126
        .build();
1127
    final Action insertHorizontalRuleAction = Action
1128
        .builder()
1129
        .setText( "Main.menu.insert.horizontal_rule" )
1130
        .setAccelerator( "Shortcut+H" )
1131
        .setAction( e -> insertMarkdown(
1132
            "\n\n---\n\n", "" ) )
1133
        .setDisabled( activeFileEditorIsNull )
1134
        .build();
1135
1136
    // Definition actions
1137
    final Action definitionCreateAction = Action
1138
        .builder()
1139
        .setText( "Main.menu.definition.create" )
1140
        .setIcon( TREE )
1141
        .setAction( e -> getDefinitionPane().addItem() )
1142
        .build();
1143
    final Action definitionInsertAction = Action
1144
        .builder()
1145
        .setText( "Main.menu.definition.insert" )
1146
        .setAccelerator( "Ctrl+Space" )
1147
        .setIcon( STAR )
1148
        .setAction( e -> definitionInsert() )
1149
        .build();
1150
1151
    // Help actions
1152
    final Action helpAboutAction = Action
1153
        .builder()
11431154
        .setText( "Main.menu.help.about" )
11441155
        .setAction( e -> helpAbout() )
M src/main/java/com/keenwrite/Messages.java
6969
          if( i + 1 < len && s.charAt( i + 1 ) == '{' ) {
7070
            stack.push( sb );
71
72
            if( stack.size() > 20 ) {
73
              final var m = get( "Main.status.error.messages.recursion", s );
74
              throw new IllegalArgumentException( m );
75
            }
76
7177
            sb = new StringBuilder( 256 );
7278
            i++;
...
96102
97103
    if( open ) {
98
      throw new IllegalArgumentException( "missing '}'" );
104
      final var m = get( "Main.status.error.messages.syntax", s );
105
      throw new IllegalArgumentException( m );
99106
    }
100107
M src/main/java/com/keenwrite/StatusBarNotifier.java
9090
9191
  /**
92
   * Returns the global {@link Notifier} instance that can be used for opening
93
   * pop-up alert messages.
94
   *
95
   * @return The pop-up {@link Notifier} dispatcher.
96
   */
97
  public static Notifier getNotifier() {
98
    return sNotifier;
99
  }
100
101
  /**
92102
   * Updates the status bar to show the first line of the given message.
93103
   *
94104
   * @param message The message to show in the status bar.
95105
   */
96106
  private static void update( final String message ) {
107
    try {
108
      throw new RuntimeException();
109
    } catch( final Exception e ) {
110
      e.printStackTrace();
111
    }
112
97113
    runLater(
98114
        () -> {
99115
          final var s = message == null ? "" : message;
100116
          final var i = s.indexOf( '\n' );
101117
          sStatusBar.setText( s.substring( 0, i > 0 ? i : s.length() ) );
102118
        }
103119
    );
104
  }
105
106
  /**
107
   * Returns the global {@link Notifier} instance that can be used for opening
108
   * pop-up alert messages.
109
   *
110
   * @return The pop-up {@link Notifier} dispatcher.
111
   */
112
  public static Notifier getNotifier() {
113
    return sNotifier;
114120
  }
115121
}
M src/main/java/com/keenwrite/adapters/DocumentAdapter.java
3333
3434
/**
35
 * Allows subclasses to implement specific events.
35
 * Allows subclasses to implement only specific events of interest.
3636
 */
3737
public class DocumentAdapter implements DocumentListener {
M src/main/java/com/keenwrite/adapters/ReplacedElementAdapter.java
3232
import org.xhtmlrenderer.simple.extend.FormSubmissionListener;
3333
34
/**
35
 * Allows subclasses to implement only specific events of interest.
36
 */
3437
public abstract class ReplacedElementAdapter implements ReplacedElementFactory {
3538
  @Override
M src/main/java/com/keenwrite/editors/EditorPane.java
168168
169169
  /**
170
   * Notifies observers when the caret changes paragraph.
171
   *
172
   * @param listener Receives change event.
173
   */
174
  public void addCaretParagraphListener(
175
      final ChangeListener<? super Integer> listener ) {
176
    getEditor().currentParagraphProperty().addListener( listener );
177
  }
178
179
  /**
180170
   * Notifies observers when the caret changes position.
181171
   *
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditorPane.java
3131
import com.keenwrite.dialogs.LinkDialog;
3232
import com.keenwrite.editors.EditorPane;
33
import com.keenwrite.processors.markdown.BlockExtension;
3433
import com.keenwrite.processors.markdown.MarkdownProcessor;
3534
import com.vladsch.flexmark.ast.Link;
36
import com.vladsch.flexmark.html.renderer.AttributablePart;
37
import com.vladsch.flexmark.util.ast.Node;
38
import com.vladsch.flexmark.util.html.MutableAttributes;
3935
import javafx.scene.control.Dialog;
4036
import javafx.scene.control.IndexRange;
4137
import javafx.scene.input.KeyCode;
4238
import javafx.scene.input.KeyEvent;
4339
import javafx.stage.Window;
4440
import org.fxmisc.richtext.StyleClassedTextArea;
4541
4642
import java.nio.file.Path;
47
import java.util.ArrayList;
48
import java.util.List;
4943
import java.util.regex.Matcher;
5044
import java.util.regex.Pattern;
...
6357
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
6458
      "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
65
66
  /**
67
   * Any of these followed by a space and a letter produce a line
68
   * by themselves. The ">" need not be followed by a space.
69
   */
70
  private static final Pattern PATTERN_NEW_LINE = Pattern.compile(
71
      "^>|(((#+)|([*+\\-])|([1-9]\\.))\\s+).+" );
7259
7360
  public MarkdownEditorPane() {
...
9279
  public void insertImage() {
9380
    insertObject( createImageDialog() );
94
  }
95
96
  /**
97
   * Returns the editor's paragraph number that will be close to its HTML
98
   * paragraph ID. Ultimately this solution is flawed because there isn't
99
   * a straightforward correlation between the document being edited and
100
   * what is rendered. XML documents transformed through stylesheets have
101
   * no readily determined correlation. Images, tables, and other
102
   * objects affect the relative location of the current paragraph being
103
   * edited with respect to the preview pane.
104
   * <p>
105
   * See
106
   * {@link BlockExtension.IdAttributeProvider#setAttributes(Node, AttributablePart, MutableAttributes)}}
107
   * for details.
108
   * </p>
109
   * <p>
110
   * Injecting a token into the document, as per a previous version of the
111
   * application, can instruct the preview pane where to shift the viewport.
112
   * </p>
113
   *
114
   * @param paraIndex The paragraph index from the editor pane to scroll to
115
   *                  in the preview pane, which  will be approximated if an
116
   *                  equivalent cannot be found.
117
   * @return A unique identifier that correlates to an equivalent paragraph
118
   * number once the Markdown is rendered into HTML.
119
   */
120
  public int approximateParagraphId( final int paraIndex ) {
121
    final StyleClassedTextArea editor = getEditor();
122
    final List<String> lines = new ArrayList<>( 4096 );
123
124
    int i = 0;
125
    String prevText = "";
126
    boolean withinFencedBlock = false;
127
    boolean withinCodeBlock = false;
128
129
    for( final var p : editor.getParagraphs() ) {
130
      if( i > paraIndex ) {
131
        break;
132
      }
133
134
      final String text = p.getText().replace( '>', ' ' );
135
      if( text.startsWith( "```" ) ) {
136
        if( withinFencedBlock = !withinFencedBlock ) {
137
          lines.add( text );
138
        }
139
      }
140
141
      if( !withinFencedBlock ) {
142
        final boolean foundCodeBlock = text.startsWith( "    " );
143
144
        if( foundCodeBlock && !withinCodeBlock ) {
145
          lines.add( text );
146
          withinCodeBlock = true;
147
        }
148
        else if( !foundCodeBlock ) {
149
          withinCodeBlock = false;
150
        }
151
      }
152
153
      if( !withinFencedBlock && !withinCodeBlock &&
154
          ((!text.isBlank() && prevText.isBlank()) ||
155
              PATTERN_NEW_LINE.matcher( text ).matches()) ) {
156
        lines.add( text );
157
      }
158
159
      prevText = text;
160
      i++;
161
    }
162
163
    // Scrolling index is 1-based.
164
    return Math.max( lines.size() - 1, 0 );
16581
  }
16682
...
315231
   */
316232
  private HyperlinkModel getHyperlink() {
317
    final StyleClassedTextArea textArea = getEditor();
318
    final String selectedText = textArea.getSelectedText();
233
    final var textArea = getEditor();
234
    final var selectedText = textArea.getSelectedText();
319235
320236
    // Get the current paragraph, convert to Markdown nodes.
321
    final MarkdownProcessor mp = MarkdownProcessor.create();
322
    final int p = textArea.getCurrentParagraph();
323
    final String paragraph = textArea.getText( p );
324
    final Node node = mp.toNode( paragraph );
325
    final LinkVisitor visitor = new LinkVisitor( textArea.getCaretColumn() );
326
    final Link link = visitor.process( node );
237
    final var mp = MarkdownProcessor.create();
238
    final var p = textArea.getCurrentParagraph();
239
    final var paragraph = textArea.getText( p );
240
    final var node = mp.toNode( paragraph );
241
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
242
    final var link = visitor.process( node );
327243
328244
    if( link != null ) {
...
346262
  private Path getParentPath() {
347263
    final Path path = getPath();
348
    return (path != null) ? path.getParent() : null;
264
    return path != null ? path.getParent() : null;
349265
  }
350266
M src/main/java/com/keenwrite/preferences/UserPreferences.java
8989
  private final StringProperty mPropImagesOrder;
9090
  private final ObjectProperty<File> mPropDefinitionPath;
91
  private final StringProperty mRDelimiterBegan;
92
  private final StringProperty mRDelimiterEnded;
93
  private final StringProperty mDefDelimiterBegan;
94
  private final StringProperty mDefDelimiterEnded;
91
  private final StringProperty mPropRDelimBegan;
92
  private final StringProperty mPropRDelimEnded;
93
  private final StringProperty mPropDefDelimBegan;
94
  private final StringProperty mPropDefDelimEnded;
9595
  private final IntegerProperty mPropFontsSizeEditor;
9696
...
106106
    );
107107
108
    mDefDelimiterBegan = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT );
109
    mDefDelimiterEnded = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT );
108
    mPropDefDelimBegan = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT );
109
    mPropDefDelimEnded = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT );
110110
111
    mRDelimiterBegan = new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT );
112
    mRDelimiterEnded = new SimpleStringProperty( R_DELIM_ENDED_DEFAULT );
111
    mPropRDelimBegan = new SimpleStringProperty( R_DELIM_BEGAN_DEFAULT );
112
    mPropRDelimEnded = new SimpleStringProperty( R_DELIM_ENDED_DEFAULT );
113113
114114
    mPropFontsSizeEditor = new SimpleIntegerProperty( (int) FONT_SIZE_EDITOR );
...
166166
                get( "Preferences.r.delimiter.began" ),
167167
                Setting.of( label( "Preferences.r.delimiter.began.desc" ) ),
168
                Setting.of( "Opening", mRDelimiterBegan )
168
                Setting.of( "Opening", mPropRDelimBegan )
169169
            ),
170170
            Group.of(
171171
                get( "Preferences.r.delimiter.ended" ),
172172
                Setting.of( label( "Preferences.r.delimiter.ended.desc" ) ),
173
                Setting.of( "Closing", mRDelimiterEnded )
173
                Setting.of( "Closing", mPropRDelimEnded )
174174
            )
175175
        ),
...
198198
                Setting.of( label(
199199
                    "Preferences.definitions.delimiter.began.desc" ) ),
200
                Setting.of( "Opening", mDefDelimiterBegan )
200
                Setting.of( "Opening", mPropDefDelimBegan )
201201
            ),
202202
            Group.of(
203203
                get( "Preferences.definitions.delimiter.ended" ),
204204
                Setting.of( label(
205205
                    "Preferences.definitions.delimiter.ended.desc" ) ),
206
                Setting.of( "Closing", mDefDelimiterEnded )
206
                Setting.of( "Closing", mPropDefDelimEnded )
207207
            )
208208
        ),
...
287287
288288
  private StringProperty defDelimiterBegan() {
289
    return mDefDelimiterBegan;
289
    return mPropDefDelimBegan;
290290
  }
291291
292292
  public String getDefDelimiterBegan() {
293293
    return defDelimiterBegan().get();
294294
  }
295295
296296
  private StringProperty defDelimiterEnded() {
297
    return mDefDelimiterEnded;
297
    return mPropDefDelimEnded;
298298
  }
299299
...
319319
320320
  private StringProperty rDelimiterBegan() {
321
    return mRDelimiterBegan;
321
    return mPropRDelimBegan;
322322
  }
323323
324324
  public String getRDelimiterBegan() {
325325
    return rDelimiterBegan().get();
326326
  }
327327
328328
  private StringProperty rDelimiterEnded() {
329
    return mRDelimiterEnded;
329
    return mPropRDelimEnded;
330330
  }
331331
M src/main/java/com/keenwrite/preview/HTMLPreviewPane.java
3131
import javafx.beans.property.BooleanProperty;
3232
import javafx.beans.property.SimpleBooleanProperty;
33
import javafx.beans.value.ChangeListener;
34
import javafx.beans.value.ObservableValue;
35
import javafx.embed.swing.SwingNode;
36
import javafx.scene.Node;
37
import org.jsoup.Jsoup;
38
import org.jsoup.helper.W3CDom;
39
import org.xhtmlrenderer.layout.SharedContext;
40
import org.xhtmlrenderer.render.Box;
41
import org.xhtmlrenderer.simple.XHTMLPanel;
42
import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler;
43
import org.xhtmlrenderer.swing.*;
44
45
import javax.swing.*;
46
import java.awt.*;
47
import java.awt.event.ComponentAdapter;
48
import java.awt.event.ComponentEvent;
49
import java.net.URI;
50
import java.nio.file.Path;
51
52
import static com.keenwrite.Constants.*;
53
import static com.keenwrite.StatusBarNotifier.clue;
54
import static com.keenwrite.util.ProtocolResolver.getProtocol;
55
import static java.awt.Desktop.Action.BROWSE;
56
import static java.awt.Desktop.getDesktop;
57
import static java.lang.Math.max;
58
import static javax.swing.SwingUtilities.invokeLater;
59
import static org.xhtmlrenderer.swing.ImageResourceLoader.NO_OP_REPAINT_LISTENER;
60
61
/**
62
 * HTML preview pane is responsible for rendering an HTML document.
63
 */
64
public final class HTMLPreviewPane extends SwingNode {
65
66
  /**
67
   * Suppresses scrolling to the top on every key press.
68
   */
69
  private static class HTMLPanel extends XHTMLPanel {
70
    @Override
71
    public void resetScrollPosition() {
72
    }
73
  }
74
75
  /**
76
   * Suppresses scroll attempts until after the document has loaded.
77
   */
78
  private static final class DocumentEventHandler extends DocumentAdapter {
79
    private final BooleanProperty mReadyProperty = new SimpleBooleanProperty();
80
81
    public BooleanProperty readyProperty() {
82
      return mReadyProperty;
83
    }
84
85
    @Override
86
    public void documentStarted() {
87
      mReadyProperty.setValue( Boolean.FALSE );
88
    }
89
90
    @Override
91
    public void documentLoaded() {
92
      mReadyProperty.setValue( Boolean.TRUE );
93
    }
94
  }
95
96
  /**
97
   * Ensure that images are constrained to the panel width upon resizing.
98
   */
99
  private final class ResizeListener extends ComponentAdapter {
100
    @Override
101
    public void componentResized( final ComponentEvent e ) {
102
      setWidth( e );
103
    }
104
105
    @Override
106
    public void componentShown( final ComponentEvent e ) {
107
      setWidth( e );
108
    }
109
110
    /**
111
     * Sets the width of the {@link HTMLPreviewPane} so that images can be
112
     * scaled to fit. The scale factor is adjusted a bit below the full width
113
     * to prevent the horizontal scrollbar from appearing.
114
     *
115
     * @param event The component that defines the image scaling width.
116
     */
117
    private void setWidth( final ComponentEvent event ) {
118
      final int width = (int) (event.getComponent().getWidth() * .95);
119
      HTMLPreviewPane.this.mImageLoader.widthProperty().set( width );
120
    }
121
  }
122
123
  /**
124
   * Responsible for opening hyperlinks. External hyperlinks are opened in
125
   * the system's default browser; local file system links are opened in the
126
   * editor.
127
   */
128
  private static class HyperlinkListener extends LinkListener {
129
    @Override
130
    public void linkClicked( final BasicPanel panel, final String link ) {
131
      try {
132
        final var protocol = getProtocol( link );
133
134
        switch( protocol ) {
135
          case HTTP:
136
            final var desktop = getDesktop();
137
138
            if( desktop.isSupported( BROWSE ) ) {
139
              desktop.browse( new URI( link ) );
140
            }
141
            break;
142
          case FILE:
143
            // TODO: #88 -- publish a message to the event bus.
144
            break;
145
        }
146
      } catch( final Exception ex ) {
147
        clue( ex );
148
      }
149
    }
150
  }
151
152
  /**
153
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
154
   * poor rendering.
155
   */
156
  private static final String HTML_PREFIX = "<!DOCTYPE html>"
157
      + "<html>"
158
      + "<head>"
159
      + "<link rel='stylesheet' href='" +
160
      HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW ) + "'/>"
161
      + "</head>"
162
      + "<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();
169
170
  private static final W3CDom W3C_DOM = new W3CDom();
171
  private static final XhtmlNamespaceHandler NS_HANDLER =
172
      new XhtmlNamespaceHandler();
173
174
  /**
175
   * The buffer is reused so that previous memory allocations need not repeat.
176
   */
177
  private final StringBuilder mHtmlDocument = new StringBuilder( 65536 );
178
179
  private final HTMLPanel mHtmlRenderer = new HTMLPanel();
180
  private final JScrollPane mScrollPane = new JScrollPane( mHtmlRenderer );
181
  private final DocumentEventHandler mDocHandler = new DocumentEventHandler();
182
  private final CustomImageLoader mImageLoader = new CustomImageLoader();
183
184
  private Path mPath = DEFAULT_DIRECTORY;
185
186
  /**
187
   * Creates a new preview pane that can scroll to the caret position within the
188
   * document.
189
   */
190
  public HTMLPreviewPane() {
191
    setStyle( "-fx-background-color: white;" );
192
193
    // No need to append same prefix each time the HTML content is updated.
194
    mHtmlDocument.append( HTML_PREFIX );
195
196
    // Inject an SVG renderer that produces high-quality SVG buffered images.
197
    final var factory = new ChainedReplacedElementFactory();
198
    factory.addFactory( new SvgReplacedElementFactory() );
199
    factory.addFactory( new SwingReplacedElementFactory(
200
        NO_OP_REPAINT_LISTENER, mImageLoader ) );
201
202
    final var context = getSharedContext();
203
    final var textRenderer = context.getTextRenderer();
204
    context.setReplacedElementFactory( factory );
205
    textRenderer.setSmoothingThreshold( 0 );
206
207
    setContent( mScrollPane );
208
    mHtmlRenderer.addDocumentListener( mDocHandler );
209
    mHtmlRenderer.addComponentListener( new ResizeListener() );
210
211
    // The default mouse click listener attempts navigation within the
212
    // preview panel. We want to usurp that behaviour to open the link in
213
    // a platform-specific browser.
214
    for( final var listener : mHtmlRenderer.getMouseTrackingListeners() ) {
215
      if( !(listener instanceof HoverListener) ) {
216
        mHtmlRenderer.removeMouseTrackingListener( (FSMouseListener) listener );
217
      }
218
    }
219
220
    mHtmlRenderer.addMouseTrackingListener( new HyperlinkListener() );
221
  }
222
223
  /**
224
   * Updates the internal HTML source, loads it into the preview pane, then
225
   * scrolls to the caret position.
226
   *
227
   * @param html The new HTML document to display.
228
   */
229
  public void process( final String html ) {
230
    final var docJsoup = Jsoup.parse( decorate( html ) );
231
    final var docW3c = W3C_DOM.fromJsoup( docJsoup );
232
233
    // Access to a Swing component must occur from the Event Dispatch
234
    // Thread (EDT) according to Swing threading restrictions.
235
    invokeLater(
236
        () -> mHtmlRenderer.setDocument( docW3c, getBaseUrl(), NS_HANDLER )
237
    );
238
  }
239
240
  /**
241
   * Clears the preview pane by rendering an empty string.
242
   */
243
  public void clear() {
244
    process( "" );
245
  }
246
247
  /**
248
   * Scrolls to an anchor link. The anchor links are injected when the
249
   * HTML document is created.
250
   *
251
   * @param id The unique anchor link identifier.
252
   */
253
  public void tryScrollTo( final int id ) {
254
    final ChangeListener<Boolean> listener = new ChangeListener<>() {
255
      @Override
256
      public void changed(
257
          final ObservableValue<? extends Boolean> observable,
258
          final Boolean oldValue,
259
          final Boolean newValue ) {
260
        if( newValue ) {
261
          scrollTo( id );
262
263
          mDocHandler.readyProperty().removeListener( this );
264
        }
265
      }
266
    };
267
268
    mDocHandler.readyProperty().addListener( listener );
269
  }
270
271
  /**
272
   * Scrolls to the closest element matching the given identifier without
273
   * waiting for the document to be ready. Be sure the document is ready
274
   * before calling this method.
275
   *
276
   * @param id Paragraph index.
277
   */
278
  public void scrollTo( final int id ) {
279
    if( id < 2 ) {
280
      scrollToTop();
281
    }
282
    else {
283
      Box box = findPrevBox( id );
284
      box = box == null ? findNextBox( id + 1 ) : box;
285
286
      if( box == null ) {
287
        scrollToBottom();
288
      }
289
      else {
290
        scrollTo( box );
291
      }
292
    }
293
  }
294
295
  private Box findPrevBox( final int id ) {
296
    int prevId = id;
297
    Box box = null;
298
299
    while( prevId > 0 && (box = getBoxById( PARAGRAPH_ID_PREFIX + prevId )) == null ) {
300
      prevId--;
301
    }
302
303
    return box;
304
  }
305
306
  private Box findNextBox( final int id ) {
307
    int nextId = id;
308
    Box box = null;
309
310
    while( nextId - id < 5 &&
311
        (box = getBoxById( PARAGRAPH_ID_PREFIX + nextId )) == null ) {
312
      nextId++;
313
    }
314
315
    return box;
316
  }
317
318
  private void scrollTo( final Point point ) {
319
    invokeLater( () -> mHtmlRenderer.scrollTo( point ) );
320
  }
321
322
  private void scrollTo( final Box box ) {
323
    scrollTo( createPoint( box ) );
324
  }
325
326
  private void scrollToY( final int y ) {
327
    scrollTo( new Point( 0, y ) );
328
  }
329
330
  private void scrollToTop() {
331
    scrollToY( 0 );
332
  }
333
334
  private void scrollToBottom() {
335
    scrollToY( mHtmlRenderer.getHeight() );
336
  }
337
338
  private Box getBoxById( final String id ) {
339
    return getSharedContext().getBoxById( id );
340
  }
341
342
  private String decorate( final String html ) {
343
    // Trim the HTML back to only the prefix.
344
    mHtmlDocument.setLength( HTML_PREFIX_LENGTH );
345
346
    // Write the HTML body element followed by closing tags.
347
    return mHtmlDocument.append( html ).toString();
348
  }
349
350
  public Path getPath() {
351
    return mPath;
352
  }
353
354
  public void setPath( final Path path ) {
355
    assert path != null;
356
    mPath = path;
357
  }
358
359
  /**
360
   * Content to embed in a panel.
361
   *
362
   * @return The content to display to the user.
363
   */
364
  public Node getNode() {
365
    return this;
366
  }
367
368
  public JScrollPane getScrollPane() {
369
    return mScrollPane;
370
  }
371
372
  public JScrollBar getVerticalScrollBar() {
373
    return getScrollPane().getVerticalScrollBar();
374
  }
375
376
  /**
377
   * Creates a {@link Point} to use as a reference for scrolling to the area
378
   * described by the given {@link Box}. The {@link Box} coordinates are used
379
   * to populate the {@link Point}'s location, with minor adjustments for
380
   * vertical centering.
381
   *
382
   * @param box The {@link Box} that represents a scrolling anchor reference.
383
   * @return A coordinate suitable for scrolling to.
384
   */
385
  private Point createPoint( final Box box ) {
386
    assert box != null;
387
388
    int x = box.getAbsX();
389
390
    // Scroll back up by half the height of the scroll bar to keep the typing
391
    // area within the view port. Otherwise the view port will have jumped too
392
    // high up and the whatever gets typed won't be visible.
393
    int y = max(
394
        box.getAbsY() - (mScrollPane.getVerticalScrollBar().getHeight() / 2),
395
        0 );
396
397
    if( !box.getStyle().isInline() ) {
398
      final var margin = box.getMargin( mHtmlRenderer.getLayoutContext() );
399
      x += margin.left();
400
      y += margin.top();
401
    }
402
403
    return new Point( x, y );
404
  }
405
406
  private String getBaseUrl() {
407
    final Path basePath = getPath();
408
    final Path parent = basePath == null ? null : basePath.getParent();
33
import javafx.embed.swing.SwingNode;
34
import javafx.scene.Node;
35
import org.jsoup.Jsoup;
36
import org.jsoup.helper.W3CDom;
37
import org.xhtmlrenderer.layout.SharedContext;
38
import org.xhtmlrenderer.render.Box;
39
import org.xhtmlrenderer.simple.XHTMLPanel;
40
import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler;
41
import org.xhtmlrenderer.swing.*;
42
43
import javax.swing.*;
44
import java.awt.*;
45
import java.awt.event.ComponentAdapter;
46
import java.awt.event.ComponentEvent;
47
import java.net.URI;
48
import java.nio.file.Path;
49
50
import static com.keenwrite.Constants.DEFAULT_DIRECTORY;
51
import static com.keenwrite.Constants.STYLESHEET_PREVIEW;
52
import static com.keenwrite.StatusBarNotifier.clue;
53
import static com.keenwrite.util.ProtocolResolver.getProtocol;
54
import static java.awt.Desktop.Action.BROWSE;
55
import static java.awt.Desktop.getDesktop;
56
import static java.lang.Math.max;
57
import static java.lang.String.format;
58
import static javax.swing.SwingUtilities.invokeLater;
59
import static org.xhtmlrenderer.swing.ImageResourceLoader.NO_OP_REPAINT_LISTENER;
60
61
/**
62
 * HTML preview pane is responsible for rendering an HTML document.
63
 */
64
public final class HTMLPreviewPane extends SwingNode {
65
  /**
66
   * Used to scroll to the top of the preview pane.
67
   */
68
  private static final Point POINT_TOP = new Point( 0, 0 );
69
70
  /**
71
   * Suppresses scrolling to the top on every key press.
72
   */
73
  private static class HTMLPanel extends XHTMLPanel {
74
    @Override
75
    public void resetScrollPosition() {
76
    }
77
  }
78
79
  /**
80
   * Suppresses scroll attempts until after the document has loaded.
81
   */
82
  private static final class DocumentEventHandler extends DocumentAdapter {
83
    private final BooleanProperty mReadyProperty = new SimpleBooleanProperty();
84
85
    public BooleanProperty readyProperty() {
86
      return mReadyProperty;
87
    }
88
89
    @Override
90
    public void documentStarted() {
91
      mReadyProperty.setValue( Boolean.FALSE );
92
    }
93
94
    @Override
95
    public void documentLoaded() {
96
      mReadyProperty.setValue( Boolean.TRUE );
97
    }
98
  }
99
100
  /**
101
   * Ensure that images are constrained to the panel width upon resizing.
102
   */
103
  private final class ResizeListener extends ComponentAdapter {
104
    @Override
105
    public void componentResized( final ComponentEvent e ) {
106
      setWidth( e );
107
    }
108
109
    @Override
110
    public void componentShown( final ComponentEvent e ) {
111
      setWidth( e );
112
    }
113
114
    /**
115
     * Sets the width of the {@link HTMLPreviewPane} so that images can be
116
     * scaled to fit. The scale factor is adjusted a bit below the full width
117
     * to prevent the horizontal scrollbar from appearing.
118
     *
119
     * @param event The component that defines the image scaling width.
120
     */
121
    private void setWidth( final ComponentEvent event ) {
122
      final int width = (int) (event.getComponent().getWidth() * .95);
123
      HTMLPreviewPane.this.mImageLoader.widthProperty().set( width );
124
    }
125
  }
126
127
  /**
128
   * Responsible for opening hyperlinks. External hyperlinks are opened in
129
   * the system's default browser; local file system links are opened in the
130
   * editor.
131
   */
132
  private static class HyperlinkListener extends LinkListener {
133
    @Override
134
    public void linkClicked( final BasicPanel panel, final String link ) {
135
      try {
136
        switch( getProtocol( link ) ) {
137
          case HTTP:
138
            final var desktop = getDesktop();
139
140
            if( desktop.isSupported( BROWSE ) ) {
141
              desktop.browse( new URI( link ) );
142
            }
143
            break;
144
          case FILE:
145
            // TODO: #88 -- publish a message to the event bus.
146
            break;
147
        }
148
      } catch( final Exception ex ) {
149
        clue( ex );
150
      }
151
    }
152
  }
153
154
  /**
155
   * Render CSS using points (pt) not pixels (px) to reduce the chance of
156
   * poor rendering.
157
   */
158
  private static final String HTML_PREFIX = format(
159
      "<!DOCTYPE html>"
160
          + "<html lang='en'>"
161
          + "<head><title> </title><meta charset='utf-8'/>"
162
          + "<link rel='stylesheet' href='%s'/>"
163
          + "</head>"
164
          + "<body>",
165
      HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW )
166
  );
167
168
  private static final String HTML_SUFFIX = "</body></html>";
169
170
  /**
171
   * Used to reset the {@link #mHtmlDocument} buffer so that the
172
   * {@link #HTML_PREFIX} need not be appended all the time.
173
   */
174
  private static final int HTML_PREFIX_LENGTH = HTML_PREFIX.length();
175
176
  private static final W3CDom W3C_DOM = new W3CDom();
177
  private static final XhtmlNamespaceHandler NS_HANDLER =
178
      new XhtmlNamespaceHandler();
179
180
  /**
181
   * The buffer is reused so that previous memory allocations need not repeat.
182
   */
183
  private final StringBuilder mHtmlDocument = new StringBuilder( 65536 );
184
185
  private final HTMLPanel mHtmlRenderer = new HTMLPanel();
186
  private final JScrollPane mScrollPane = new JScrollPane( mHtmlRenderer );
187
  private final CustomImageLoader mImageLoader = new CustomImageLoader();
188
189
  private Path mPath = DEFAULT_DIRECTORY;
190
191
  /**
192
   * Creates a new preview pane that can scroll to the caret position within the
193
   * document.
194
   */
195
  public HTMLPreviewPane() {
196
    setStyle( "-fx-background-color: white;" );
197
198
    // No need to append same prefix each time the HTML content is updated.
199
    mHtmlDocument.append( HTML_PREFIX );
200
201
    // Inject an SVG renderer that produces high-quality SVG buffered images.
202
    final var factory = new ChainedReplacedElementFactory();
203
    factory.addFactory( new SvgReplacedElementFactory() );
204
    factory.addFactory( new SwingReplacedElementFactory(
205
        NO_OP_REPAINT_LISTENER, mImageLoader ) );
206
207
    final var context = getSharedContext();
208
    final var textRenderer = context.getTextRenderer();
209
    context.setReplacedElementFactory( factory );
210
    textRenderer.setSmoothingThreshold( 0 );
211
212
    setContent( mScrollPane );
213
    mHtmlRenderer.addDocumentListener( new DocumentEventHandler() );
214
    mHtmlRenderer.addComponentListener( new ResizeListener() );
215
216
    // The default mouse click listener attempts navigation within the
217
    // preview panel. We want to usurp that behaviour to open the link in
218
    // a platform-specific browser.
219
    for( final var listener : mHtmlRenderer.getMouseTrackingListeners() ) {
220
      if( !(listener instanceof HoverListener) ) {
221
        mHtmlRenderer.removeMouseTrackingListener( (FSMouseListener) listener );
222
      }
223
    }
224
225
    mHtmlRenderer.addMouseTrackingListener( new HyperlinkListener() );
226
  }
227
228
  /**
229
   * Updates the internal HTML source, loads it into the preview pane, then
230
   * scrolls to the caret position.
231
   *
232
   * @param html The new HTML document to display.
233
   */
234
  public void process( final String html ) {
235
    final var docJsoup = Jsoup.parse( decorate( html ) );
236
    final var docW3c = W3C_DOM.fromJsoup( docJsoup );
237
238
    // Access to a Swing component must occur from the Event Dispatch
239
    // Thread (EDT) according to Swing threading restrictions.
240
    invokeLater(
241
        () -> mHtmlRenderer.setDocument( docW3c, getBaseUrl(), NS_HANDLER )
242
    );
243
  }
244
245
  /**
246
   * Clears the preview pane by rendering an empty string.
247
   */
248
  public void clear() {
249
    process( "" );
250
  }
251
252
  /**
253
   * Scrolls to the closest element matching the given identifier without
254
   * waiting for the document to be ready. Be sure the document is ready
255
   * before calling this method.
256
   *
257
   * @param id Scroll the preview pane to this unique paragraph identifier.
258
   */
259
  public void scrollTo( final String id ) {
260
    scrollTo( getBoxById( id ) );
261
  }
262
263
  private void scrollTo( final Box box ) {
264
    scrollTo( box == null ? POINT_TOP : createPoint( box ) );
265
  }
266
267
  private void scrollTo( final Point point ) {
268
    invokeLater( () -> mHtmlRenderer.scrollTo( point ) );
269
  }
270
271
  private Box getBoxById( final String id ) {
272
    return getSharedContext().getBoxById( id );
273
  }
274
275
  private String decorate( final String html ) {
276
    // Trim the HTML back to only the prefix.
277
    mHtmlDocument.setLength( HTML_PREFIX_LENGTH );
278
279
    // Write the HTML body element followed by closing tags.
280
    return mHtmlDocument.append( html ).append( HTML_SUFFIX ).toString();
281
  }
282
283
  public Path getPath() {
284
    return mPath;
285
  }
286
287
  public void setPath( final Path path ) {
288
    assert path != null;
289
    mPath = path;
290
  }
291
292
  /**
293
   * Content to embed in a panel.
294
   *
295
   * @return The content to display to the user.
296
   */
297
  public Node getNode() {
298
    return this;
299
  }
300
301
  public void repaintScrollPane() {
302
    invokeLater( () -> getScrollPane().repaint() );
303
  }
304
305
  public JScrollBar getVerticalScrollBar() {
306
    return getScrollPane().getVerticalScrollBar();
307
  }
308
309
  /**
310
   * Creates a {@link Point} to use as a reference for scrolling to the area
311
   * described by the given {@link Box}. The {@link Box} coordinates are used
312
   * to populate the {@link Point}'s location, with minor adjustments for
313
   * vertical centering.
314
   *
315
   * @param box The {@link Box} that represents a scrolling anchor reference.
316
   * @return A coordinate suitable for scrolling to.
317
   */
318
  private Point createPoint( final Box box ) {
319
    assert box != null;
320
321
    int x = box.getAbsX();
322
323
    // Scroll back up by half the height of the scroll bar to keep the typing
324
    // area within the view port. Otherwise the view port will have jumped too
325
    // high up and the most recently typed letters won't be visible.
326
    int y = max(
327
        box.getAbsY() - (mScrollPane.getVerticalScrollBar().getHeight() / 2),
328
        0 );
329
330
    if( !box.getStyle().isInline() ) {
331
      final var margin = box.getMargin( mHtmlRenderer.getLayoutContext() );
332
      x += margin.left();
333
      y += margin.top();
334
    }
335
336
    return new Point( x, y );
337
  }
338
339
  private JScrollPane getScrollPane() {
340
    return mScrollPane;
341
  }
342
343
  private String getBaseUrl() {
344
    final var basePath = getPath();
345
    final var parent = basePath == null ? null : basePath.getParent();
409346
410347
    return parent == null ? "" : parent.toUri().toString();
M src/main/java/com/keenwrite/processors/IdentityProcessor.java
2929
3030
/**
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.
31
 * Responsible for transforming a string into itself. This is used at the
32
 * end of a processing chain when no more processing is required.
3433
 */
3534
public class IdentityProcessor extends AbstractProcessor<String> {
35
  public static final IdentityProcessor INSTANCE = new IdentityProcessor();
3636
3737
  /**
38
   * Passes the link to the super constructor.
39
   *
40
   * @param successor The next processor in the chain to use for text
41
   *                  processing.
38
   * Constructs a new instance having no successor (the default successor is
39
   * {@code null}).
4240
   */
43
  public IdentityProcessor( final Processor<String> successor ) {
44
    super( successor );
41
  private IdentityProcessor() {
4542
  }
4643
M src/main/java/com/keenwrite/processors/InlineRProcessor.java
4242
4343
import static com.keenwrite.Constants.STATUS_PARSE_ERROR;
44
import static com.keenwrite.StatusBarNotifier.clue;
4544
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
4645
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
...
116115
      final var dir = wd.toString().replace( '\\', '/' );
117116
      final var map = getDefinitions();
118
      map.put( "$application.r.working.directory$", dir );
117
      final var prefs = UserPreferences.getInstance();
118
      final var defBegan = prefs.getDefDelimiterBegan();
119
      final var defEnded = prefs.getDefDelimiterEnded();
120
121
      map.put( defBegan + "application.r.working.directory" + defEnded, dir );
119122
120123
      eval( replace( bootstrap, map ) );
...
189192
190193
          // Tell the user that there was a problem.
191
          StatusBarNotifier.clue( STATUS_PARSE_ERROR, e.getMessage(), currIndex );
194
          StatusBarNotifier.clue( STATUS_PARSE_ERROR,
195
                                  e.getMessage(),
196
                                  currIndex );
192197
        }
193198
M src/main/java/com/keenwrite/processors/ProcessorContext.java
2929
3030
import com.keenwrite.ExportFormat;
31
import com.keenwrite.FileEditorTab;
3132
import com.keenwrite.FileType;
3233
import com.keenwrite.preview.HTMLPreviewPane;
34
import com.keenwrite.processors.markdown.CaretPosition;
3335
3436
import java.nio.file.Path;
...
4446
  private final Map<String, String> mResolvedMap;
4547
  private final ExportFormat mExportFormat;
46
  private final FileType mFileType;
47
  private final Path mPath;
48
48
  private final FileEditorTab mTab;
4949
5050
  /**
5151
   * Creates a new context for use by the {@link ProcessorFactory} when
5252
   * instantiating new {@link Processor} instances. Although all the
5353
   * parameters are required, not all {@link Processor} instances will use
5454
   * all parameters.
5555
   *
5656
   * @param previewPane Where to display the final (HTML) output.
5757
   * @param resolvedMap Fully expanded interpolated strings.
58
   * @param path        Path to the document to process.
58
   * @param tab         Tab containing path to the document to process.
5959
   * @param format      Indicate configuration options for export format.
6060
   */
6161
  public ProcessorContext(
6262
      final HTMLPreviewPane previewPane,
6363
      final Map<String, String> resolvedMap,
64
      final Path path,
64
      final FileEditorTab tab,
6565
      final ExportFormat format ) {
66
    assert previewPane != null;
67
    assert resolvedMap != null;
68
    assert tab != null;
69
    assert format != null;
70
6671
    mPreviewPane = previewPane;
6772
    mResolvedMap = resolvedMap;
68
    mPath = path;
69
    mFileType = lookup( path );
73
    mTab = tab;
7074
    mExportFormat = format;
75
  }
76
77
  @SuppressWarnings("SameParameterValue")
78
  boolean isExportFormat( final ExportFormat format ) {
79
    return mExportFormat == format;
7180
  }
7281
...
7988
  }
8089
81
  public Path getPath() {
82
    return mPath;
90
  public ExportFormat getExportFormat() {
91
    return mExportFormat;
8392
  }
8493
85
  FileType getFileType() {
86
    return mFileType;
94
  /**
95
   * Returns the current caret position in the document being edited and is
96
   * always up-to-date.
97
   *
98
   * @return Caret position in the document.
99
   */
100
  public CaretPosition getCaretPosition() {
101
    return mTab.getCaretPosition();
87102
  }
88103
89
  public ExportFormat getExportFormat() {
90
    return mExportFormat;
104
  public Path getPath() {
105
    return mTab.getPath();
91106
  }
92107
93
  @SuppressWarnings("SameParameterValue")
94
  boolean isExportFormat( final ExportFormat format ) {
95
    return mExportFormat == format;
108
  FileType getFileType() {
109
    return lookup( getPath() );
96110
  }
97111
}
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
4444
4545
  private final ProcessorContext mProcessorContext;
46
  private final Processor<String> mMarkdownProcessor;
4746
4847
  /**
4948
   * Constructs a factory with the ability to create processors that can perform
5049
   * text and caret processing to generate a final preview.
5150
   *
5251
   * @param processorContext Parameters needed to construct various processors.
5352
   */
5453
  private ProcessorFactory( final ProcessorContext processorContext ) {
5554
    mProcessorContext = processorContext;
56
    mMarkdownProcessor = createMarkdownProcessor();
5755
  }
5856
5957
  private Processor<String> createProcessor() {
6058
    final ProcessorContext context = getProcessorContext();
61
    final Processor<String> successor;
6259
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
    }
60
    // If the content is not to be exported, then the successor processor
61
    // is one that parses Markdown into HTML and passes the string to the
62
    // HTML preview pane.
63
    //
64
    // Otherwise, bolt on a processor that--after the interpolation and
65
    // substitution phase, which includes text strings or R code---will
66
    // generate HTML or plain Markdown. HTML has a few output formats:
67
    // with embedded SVG representing formulas, or without any conversion
68
    // to SVG. Without conversion would require client-side rendering of
69
    // math (such as using the JavaScript-based KaTeX engine).
70
    final Processor<String> successor = context.isExportFormat( NONE )
71
        ? createHtmlPreviewProcessor()
72
        : createIdentityProcessor();
8373
8474
    return switch( context.getFileType() ) {
8575
      case RMARKDOWN -> createRProcessor( successor );
86
      case SOURCE -> createMarkdownDefinitionProcessor( successor );
76
      case SOURCE -> createMarkdownProcessor( successor );
8777
      case RXML -> createRXMLProcessor( successor );
8878
      case XML -> createXMLProcessor( successor );
89
      default -> createPreformattedProcessor();
79
      default -> createPreformattedProcessor( successor );
9080
    };
9181
  }
...
126116
   */
127117
  private Processor<String> createIdentityProcessor() {
128
    return new IdentityProcessor( null );
118
    return IdentityProcessor.INSTANCE;
129119
  }
130120
131121
  /**
132122
   * Instantiates a new {@link Processor} that passes an incoming HTML
133123
   * string to a user interface widget that can render HTML as a web page.
134124
   *
135125
   * @return An instance of {@link Processor} that forwards HTML for display.
136126
   */
137
  private Processor<String> createHTMLPreviewProcessor() {
127
  private Processor<String> createHtmlPreviewProcessor() {
138128
    return new HtmlPreviewProcessor( getPreviewPane() );
139129
  }
140130
141131
  /**
142
   * Instantiates {@link Processor} instances that end the processing chain.
132
   * Instantiates a {@link Processor} responsible for parsing Markdown and
133
   * definitions.
143134
   *
144
   * @return A chain of {@link Processor}s that convert Markdown to HTML.
135
   * @return A chain of {@link Processor}s for processing Markdown and
136
   * definitions.
145137
   */
146
  private Processor<String> createMarkdownProcessor() {
147
    final var hpp = createHTMLPreviewProcessor();
148
    return MarkdownProcessor.create( hpp, getProcessorContext() );
149
  }
150
151
  private Processor<String> createPreformattedProcessor(
138
  private Processor<String> createMarkdownProcessor(
152139
      final Processor<String> successor ) {
153
    return new PreformattedProcessor( successor );
154
  }
155
156
  private Processor<String> createPreformattedProcessor() {
157
    return createPreformattedProcessor( createHTMLPreviewProcessor() );
140
    final var dp = createDefinitionProcessor( successor );
141
    return MarkdownProcessor.create( dp, getProcessorContext() );
158142
  }
159143
160144
  private Processor<String> createDefinitionProcessor(
161145
      final Processor<String> successor ) {
162146
    return new DefinitionProcessor( successor, getResolvedMap() );
163147
  }
164148
165149
  private Processor<String> createRProcessor(
166
      final Processor<String> successor ) {
167
    final var rp = new InlineRProcessor( successor, getResolvedMap() );
168
    return new RVariableProcessor( rp, getResolvedMap() );
169
  }
170
171
  private Processor<String> createMarkdownDefinitionProcessor(
172150
      final Processor<String> successor ) {
173
    return createDefinitionProcessor( successor );
151
    final var irp = new InlineRProcessor( successor, getResolvedMap() );
152
    final var rvp = new RVariableProcessor( irp, getResolvedMap() );
153
    return MarkdownProcessor.create( rvp, getProcessorContext() );
174154
  }
175155
176156
  protected Processor<String> createRXMLProcessor(
177157
      final Processor<String> successor ) {
178158
    final var xmlp = new XmlProcessor( successor, getPath() );
179
    final var rp = new InlineRProcessor( xmlp, getResolvedMap() );
180
    return new RVariableProcessor( rp, getResolvedMap() );
159
    return createRProcessor( xmlp );
181160
  }
182161
183162
  private Processor<String> createXMLProcessor(
184163
      final Processor<String> successor ) {
185164
    final var xmlp = new XmlProcessor( successor, getPath() );
186165
    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();
199166
  }
200167
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;
168
  private Processor<String> createPreformattedProcessor(
169
      final Processor<String> successor ) {
170
    return new PreformattedProcessor( successor );
209171
  }
210172
D src/main/java/com/keenwrite/processors/markdown/BlockExtension.java
1
package com.keenwrite.processors.markdown;
2
3
import com.vladsch.flexmark.ast.BlockQuote;
4
import com.vladsch.flexmark.ast.ListBlock;
5
import com.vladsch.flexmark.html.AttributeProvider;
6
import com.vladsch.flexmark.html.AttributeProviderFactory;
7
import com.vladsch.flexmark.html.IndependentAttributeProviderFactory;
8
import com.vladsch.flexmark.html.renderer.AttributablePart;
9
import com.vladsch.flexmark.html.renderer.LinkResolverContext;
10
import com.vladsch.flexmark.util.ast.Block;
11
import com.vladsch.flexmark.util.ast.Node;
12
import com.vladsch.flexmark.util.data.MutableDataHolder;
13
import com.vladsch.flexmark.util.html.MutableAttributes;
14
import org.jetbrains.annotations.NotNull;
15
16
import static com.keenwrite.Constants.PARAGRAPH_ID_PREFIX;
17
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
18
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
19
import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
20
21
/**
22
 * Responsible for giving most block-level elements a unique identifier
23
 * attribute. The identifier is used to coordinate scrolling.
24
 */
25
public class BlockExtension implements HtmlRendererExtension {
26
  /**
27
   * Responsible for creating the id attribute. This class is instantiated
28
   * each time the document is rendered, thereby resetting the count to zero.
29
   */
30
  public static class IdAttributeProvider implements AttributeProvider {
31
    private int mCount;
32
33
    private static AttributeProviderFactory createFactory() {
34
      return new IndependentAttributeProviderFactory() {
35
        @Override
36
        public @NotNull AttributeProvider apply(
37
            @NotNull final LinkResolverContext context ) {
38
          return new IdAttributeProvider();
39
        }
40
      };
41
    }
42
43
    @Override
44
    public void setAttributes( @NotNull Node node,
45
                               @NotNull AttributablePart part,
46
                               @NotNull MutableAttributes attributes ) {
47
      // Blockquotes are troublesome because they can interleave blank lines
48
      // without having an equivalent blank line in the source document. That
49
      // is, in Markdown the > symbol on a line by itself will generate a blank
50
      // line in the resulting document; however, a > symbol in the text editor
51
      // does not count as a blank line. Resolving this issue is tricky.
52
      //
53
      // The CODE_CONTENT represents <code> embedded inside <pre>; both elements
54
      // enter this method as FencedCodeBlock, but only the <pre> must be
55
      // uniquely identified (because they are the same line in Markdown).
56
      //
57
      if( node instanceof Block &&
58
          !(node instanceof BlockQuote) &&
59
          !(node instanceof ListBlock) &&
60
          (part != CODE_CONTENT) ) {
61
        attributes.addValue( "id", PARAGRAPH_ID_PREFIX + mCount++ );
62
      }
63
    }
64
  }
65
66
  private BlockExtension() {
67
  }
68
69
  @Override
70
  public void extend( final Builder builder,
71
                      @NotNull final String rendererType ) {
72
    builder.attributeProviderFactory( IdAttributeProvider.createFactory() );
73
  }
74
75
  public static BlockExtension create() {
76
    return new BlockExtension();
77
  }
78
79
  @Override
80
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
81
  }
82
}
831
A src/main/java/com/keenwrite/processors/markdown/CaretExtension.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;
29
30
import com.vladsch.flexmark.html.AttributeProvider;
31
import com.vladsch.flexmark.html.AttributeProviderFactory;
32
import com.vladsch.flexmark.html.IndependentAttributeProviderFactory;
33
import com.vladsch.flexmark.html.renderer.AttributablePart;
34
import com.vladsch.flexmark.html.renderer.LinkResolverContext;
35
import com.vladsch.flexmark.util.ast.Node;
36
import com.vladsch.flexmark.util.data.MutableDataHolder;
37
import com.vladsch.flexmark.util.html.AttributeImpl;
38
import com.vladsch.flexmark.util.html.MutableAttributes;
39
import org.jetbrains.annotations.NotNull;
40
41
import static com.keenwrite.Constants.CARET_ID;
42
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
43
import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension;
44
45
/**
46
 * Responsible for giving most block-level elements a unique identifier
47
 * attribute. The identifier is used to coordinate scrolling.
48
 */
49
public class CaretExtension implements HtmlRendererExtension {
50
51
  /**
52
   * Responsible for creating the id attribute. This class is instantiated
53
   * each time the document is rendered, thereby resetting the count to zero.
54
   */
55
  public static class IdAttributeProvider implements AttributeProvider {
56
    private final CaretPosition mCaret;
57
58
    public IdAttributeProvider( final CaretPosition caret ) {
59
      mCaret = caret;
60
    }
61
62
    private static AttributeProviderFactory createFactory(
63
        final CaretPosition caret ) {
64
      return new IndependentAttributeProviderFactory() {
65
        @Override
66
        public @NotNull AttributeProvider apply(
67
            @NotNull final LinkResolverContext context ) {
68
          return new IdAttributeProvider( caret );
69
        }
70
      };
71
    }
72
73
    @Override
74
    public void setAttributes( @NotNull Node curr,
75
                               @NotNull AttributablePart part,
76
                               @NotNull MutableAttributes attributes ) {
77
      final var began = curr.getStartOffset();
78
      final var ended = curr.getEndOffset();
79
      final var prev = curr.getPrevious();
80
81
      // If the caret is within the bounds of the current node or the
82
      // caret is within the bounds of the end of the previous node and
83
      // the start of the current node, then mark the current node with
84
      // a caret indicator.
85
      if( mCaret.isBetweenText( began, ended ) ||
86
          prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) {
87
        attributes.addValue( AttributeImpl.of( "id", CARET_ID ) );
88
      }
89
    }
90
  }
91
92
  private final CaretPosition mCaret;
93
94
  private CaretExtension( final CaretPosition caret ) {
95
    mCaret = caret;
96
  }
97
98
  @Override
99
  public void extend(
100
      final Builder builder, @NotNull final String rendererType ) {
101
    builder.attributeProviderFactory(
102
        IdAttributeProvider.createFactory( mCaret ) );
103
  }
104
105
  public static CaretExtension create( final CaretPosition caret ) {
106
    return new CaretExtension( caret );
107
  }
108
109
  @Override
110
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
111
  }
112
}
1113
A src/main/java/com/keenwrite/processors/markdown/CaretPosition.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;
29
30
import com.keenwrite.util.GenericBuilder;
31
import javafx.beans.value.ObservableValue;
32
import org.fxmisc.richtext.model.Paragraph;
33
import org.reactfx.collection.LiveList;
34
35
import java.util.Collection;
36
37
import static com.keenwrite.Constants.STATUS_BAR_LINE;
38
import static com.keenwrite.Messages.get;
39
40
/**
41
 * Represents the absolute, relative, and maximum position of the caret.
42
 * The caret position is a character offset into the text.
43
 */
44
public class CaretPosition {
45
46
  public static GenericBuilder<CaretPosition.Mutator, CaretPosition> builder() {
47
    return GenericBuilder.of( CaretPosition.Mutator::new, CaretPosition::new );
48
  }
49
50
  public static class Mutator {
51
    /**
52
     * Caret's current paragraph index (i.e., current caret line number).
53
     */
54
    private ObservableValue<Integer> mParagraph;
55
56
    private LiveList<Paragraph<Collection<String>, String,
57
        Collection<String>>> mParagraphs;
58
59
    /**
60
     * Caret offset into the full text, represented as a string index.
61
     */
62
    private ObservableValue<Integer> mTextOffset;
63
64
    /**
65
     * Caret offset into the current paragraph, represented as a string index.
66
     */
67
    private ObservableValue<Integer> mParaOffset;
68
69
    public void setParagraph( final ObservableValue<Integer> paragraph ) {
70
      mParagraph = paragraph;
71
    }
72
73
    public void setParagraphs(
74
        final LiveList<Paragraph<Collection<String>, String,
75
            Collection<String>>> paragraphs ) {
76
      mParagraphs = paragraphs;
77
    }
78
79
    public void setTextOffset( final ObservableValue<Integer> textOffset ) {
80
      mTextOffset = textOffset;
81
    }
82
83
    public void setParaOffset( final ObservableValue<Integer> paraOffset ) {
84
      mParaOffset = paraOffset;
85
    }
86
  }
87
88
  private final Mutator mMutator;
89
90
  /**
91
   * Force using the builder pattern.
92
   */
93
  private CaretPosition( final Mutator mutator ) {
94
    mMutator = mutator;
95
  }
96
97
  /**
98
   * Answers whether the caret's offset into the text is between the given
99
   * offsets.
100
   *
101
   * @param began Starting value compared against the caret's text offset.
102
   * @param ended Ending value compared against the caret's text offset.
103
   * @return {@code true} when the caret's text offset is between the given
104
   * values, inclusively (for either value).
105
   */
106
  public boolean isBetweenText( final int began, final int ended ) {
107
    final int offset = getTextOffset();
108
    return began <= offset && offset <= ended;
109
  }
110
111
  /**
112
   * Answers whether the caret's offset into the paragraph is before the given
113
   * offset.
114
   *
115
   * @param offset Compared against the caret's paragraph offset.
116
   * @return {@code true} the caret's offset is before the given offset.
117
   */
118
  public boolean isBeforeColumn( final int offset ) {
119
    return getParaOffset() < offset;
120
  }
121
122
  /**
123
   * Answers whether the caret's offset into the text is before the given
124
   * text offset.
125
   *
126
   * @param offset Compared against the caret's text offset.
127
   * @return {@code true} the caret's offset is after the given offset.
128
   */
129
  public boolean isAfterColumn( final int offset ) {
130
    return getParaOffset() > offset;
131
  }
132
133
  private int getParagraph() {
134
    return mMutator.mParagraph.getValue();
135
  }
136
137
  private int getParagraphCount() {
138
    return mMutator.mParagraphs.size() + 1;
139
  }
140
141
  private int getTextOffset() {
142
    return mMutator.mTextOffset.getValue();
143
  }
144
145
  private int getParaOffset() {
146
    return mMutator.mParaOffset.getValue();
147
  }
148
149
  /**
150
   * Returns a human-readable string that shows the current caret position
151
   * within the text. Typically this will include the current line number,
152
   * the number of lines, and the character offset into the text.
153
   *
154
   * @return A string to present to an end user.
155
   */
156
  @Override
157
  public String toString() {
158
    return get( STATUS_BAR_LINE,
159
                getParagraph(),
160
                getParagraphCount(),
161
                getTextOffset() );
162
  }
163
}
1164
M src/main/java/com/keenwrite/processors/markdown/ImageLinkExtension.java
4343
import java.io.File;
4444
import java.nio.file.Path;
45
import java.nio.file.Paths;
4546
4647
import static com.keenwrite.StatusBarNotifier.clue;
...
155156
156157
        if( protocol.isFile() ) {
157
          url = "file://" + url;
158
          // When generating an HTML document, ensure images use a path that's
159
          // relative to the source document. This handles displaying within
160
          // the application and when exporting to an HTML file.
161
          url = editPath.relativize( Paths.get( url ) ).toString();
158162
        }
159163
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
2929
3030
import com.keenwrite.ExportFormat;
31
import com.keenwrite.processors.AbstractProcessor;
32
import com.keenwrite.processors.Processor;
33
import com.keenwrite.processors.ProcessorContext;
31
import com.keenwrite.processors.*;
32
import com.keenwrite.processors.markdown.r.RExtension;
3433
import com.vladsch.flexmark.ext.definition.DefinitionExtension;
3534
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension;
...
4544
4645
import java.nio.file.Path;
47
import java.util.ArrayList;
4846
import java.util.Collection;
47
import java.util.HashSet;
4948
49
import static com.keenwrite.AbstractFileFactory.lookup;
5050
import static com.keenwrite.Constants.USER_DIRECTORY;
5151
import static com.keenwrite.ExportFormat.NONE;
5252
5353
/**
5454
 * Responsible for parsing a Markdown document and rendering it as HTML.
5555
 */
5656
public class MarkdownProcessor extends AbstractProcessor<String> {
5757
58
  private final IRender mRenderer;
5958
  private final IParse mParser;
59
  private final IRender mRenderer;
60
61
  private MarkdownProcessor(
62
      final Processor<String> successor,
63
      final Collection<Extension> extensions ) {
64
    super( successor );
65
66
    // TODO: https://github.com/FAlthausen/Vollkorn-Typeface/issues/38
67
    // TODO: Uncomment when ligatures are fixed.
68
    // extensions.add( LigatureExtension.create() );
69
70
    mParser = Parser.builder().extensions( extensions ).build();
71
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
72
  }
6073
6174
  public static MarkdownProcessor create() {
62
    return create( null, Path.of( USER_DIRECTORY ) );
75
    return create( IdentityProcessor.INSTANCE, Path.of( USER_DIRECTORY ) );
6376
  }
6477
6578
  public static MarkdownProcessor create(
6679
      final Processor<String> successor, final Path path ) {
6780
    final var extensions = createExtensions( path, NONE );
68
6981
    return new MarkdownProcessor( successor, extensions );
7082
  }
7183
7284
  public static MarkdownProcessor create(
7385
      final Processor<String> successor, final ProcessorContext context ) {
7486
    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
8187
    return new MarkdownProcessor( successor, extensions );
8288
  }
8389
90
  /**
91
   * Creating extensions based using an instance of {@link ProcessorContext}
92
   * indicates that the {@link CaretExtension} should be used to inject the
93
   * caret position into the final HTML document. This enables the HTML
94
   * preview pane to scroll to the same position, relatively speaking, within
95
   * the main document. Scrolling is developed this way to decouple the
96
   * document being edited from the preview pane so that multiple document
97
   * formats can be edited.
98
   *
99
   * @param context Contains necessary information needed to create extensions
100
   *                used by the Markdown parser.
101
   * @return {@link Collection} of extensions invoked when parsing Markdown.
102
   */
84103
  private static Collection<Extension> createExtensions(
85104
      final ProcessorContext context ) {
86
    return createExtensions( context.getPath(), context.getExportFormat() );
105
    final var path = context.getPath();
106
    final var format = context.getExportFormat();
107
    final var extensions = createExtensions( path, format );
108
109
    extensions.add( CaretExtension.create( context.getCaretPosition() ) );
110
111
    return extensions;
87112
  }
88113
114
  /**
115
   * Creates parser extensions that tweak the parsing engine based on various
116
   * conditions. For example, this will add a new {@link TeXExtension} that
117
   * can export TeX as either SVG or TeX macros. The tweak also includes the
118
   * ability to keep inline R statements, rather than convert them to inline
119
   * code elements, so that the {@link InlineRProcessor} can interpret the
120
   * R statements.
121
   *
122
   * @param path   Path name for referencing image files via relative paths
123
   *               and dynamic file types.
124
   * @param format TeX export format to use when generating HTMl documents.
125
   * @return {@link Collection} of extensions invoked when parsing Markdown.
126
   */
89127
  private static Collection<Extension> createExtensions(
90128
      final Path path, final ExportFormat format ) {
91129
    final var extensions = createDefaultExtensions();
92130
93
    // Allows referencing image files via relative paths and dynamic file types.
94131
    extensions.add( ImageLinkExtension.create( path ) );
95
    extensions.add( BlockExtension.create() );
96132
    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 );
105133
106
    // TODO: https://github.com/FAlthausen/Vollkorn-Typeface/issues/38
107
    // TODO: Uncomment when ligatures are fixed.
108
    // extensions.add( LigatureExtension.create() );
134
    if( lookup( path ).isR() ) {
135
      extensions.add( RExtension.create() );
136
    }
109137
110
    mRenderer = HtmlRenderer.builder().extensions( extensions ).build();
111
    mParser = Parser.builder().extensions( extensions ).build();
138
    return extensions;
112139
  }
113140
...
121148
   */
122149
  private static Collection<Extension> createDefaultExtensions() {
123
    final var extensions = new ArrayList<Extension>();
150
    final var extensions = new HashSet<Extension>();
124151
    extensions.add( DefinitionExtension.create() );
125152
    extensions.add( StrikethroughSubscriptExtension.create() );
...
177204
   * Creates the Markdown document processor.
178205
   *
179
   * @return A Parser that can build an abstract syntax tree.
206
   * @return An instance of {@link IParse} for building abstract syntax trees.
180207
   */
181208
  private IParse getParser() {
A src/main/java/com/keenwrite/processors/markdown/r/RExtension.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.r;
29
30
import com.keenwrite.processors.InlineRProcessor;
31
import com.keenwrite.sigils.RSigilOperator;
32
import com.vladsch.flexmark.ast.Text;
33
import com.vladsch.flexmark.parser.InlineParserExtensionFactory;
34
import com.vladsch.flexmark.parser.InlineParserFactory;
35
import com.vladsch.flexmark.parser.Parser;
36
import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor;
37
import com.vladsch.flexmark.parser.internal.CommonmarkInlineParser;
38
import com.vladsch.flexmark.parser.internal.LinkRefProcessorData;
39
import com.vladsch.flexmark.util.data.DataHolder;
40
import com.vladsch.flexmark.util.data.MutableDataHolder;
41
import com.vladsch.flexmark.util.sequence.BasedSequence;
42
43
import java.util.BitSet;
44
import java.util.List;
45
import java.util.Map;
46
47
/**
48
 * Responsible for preventing the Markdown engine from interpreting inline
49
 * backticks as inline code elements. This is required so that inline R code
50
 * can be executed after conversion of Markdown to HTML but before the HTML
51
 * is previewed (or exported).
52
 */
53
public final class RExtension implements Parser.ParserExtension {
54
  private final static InlineParserFactory R_INLINE_PARSER_FACTORY =
55
      RInlineParser::new;
56
57
  /**
58
   * Prevents rendering {@code `r} statements as inline HTML {@code <code>}
59
   * blocks, which allows the {@link InlineRProcessor} to post-process the
60
   * text prior to display in the preview pane. This intervention assists
61
   * with decoupling the caret from the Markdown content so that the two
62
   * can vary independently in the architecture while permitting synchronization
63
   * of the editor and preview pane.
64
   * <p>
65
   * The text is therefore processed twice: once by flexmark-java and once by
66
   * {@link InlineRProcessor}.
67
   * </p>
68
   */
69
  private static class RInlineParser extends CommonmarkInlineParser {
70
    private RInlineParser(
71
        final DataHolder options,
72
        final BitSet specialCharacters,
73
        final BitSet delimiterCharacters,
74
        final Map<Character, DelimiterProcessor> delimiterProcessors,
75
        final LinkRefProcessorData referenceLinkProcessors,
76
        final List<InlineParserExtensionFactory> inlineParserExtensions ) {
77
      super( options,
78
             specialCharacters,
79
             delimiterCharacters,
80
             delimiterProcessors,
81
             referenceLinkProcessors,
82
             inlineParserExtensions );
83
    }
84
85
    /**
86
     * The superclass handles a number backtick parsing edge cases; this method
87
     * changes the behaviour to retain R code snippets, identified by
88
     * {@link RSigilOperator#PREFIX}, so that subsequent processing can
89
     * invoke R. If other languages are added, this {@link RInlineParser} will
90
     * have to be rewritten to identify more than merely R.
91
     *
92
     * @return The return value from {@link super#parseBackticks()}.
93
     * @inheritDoc
94
     */
95
    @Override
96
    protected final boolean parseBackticks() {
97
      final var foundCode = super.parseBackticks();
98
99
      if( foundCode ) {
100
        final var block = getBlock();
101
        final var codeNode = block.getLastChild();
102
        final var code = codeNode == null
103
            ? BasedSequence.of( "" )
104
            : codeNode.getChars();
105
106
        if( code.startsWith( RSigilOperator.PREFIX ) ) {
107
          assert codeNode != null;
108
          codeNode.unlink();
109
          block.appendChild( new Text( code ) );
110
        }
111
      }
112
113
      return foundCode;
114
    }
115
  }
116
117
  private RExtension() {
118
  }
119
120
  /**
121
   * Creates an extension capable of handling delimited TeX code in Markdown.
122
   */
123
  public static RExtension create() {
124
    return new RExtension();
125
  }
126
127
  @Override
128
  public void extend( final Parser.Builder builder ) {
129
    builder.customInlineParserFactory( R_INLINE_PARSER_FACTORY );
130
  }
131
132
  @Override
133
  public void parserOptions( final MutableDataHolder options ) {
134
  }
135
}
1136
M src/main/java/com/keenwrite/util/Action.java
2828
package com.keenwrite.util;
2929
30
import com.keenwrite.Messages;
31
import de.jensd.fx.glyphs.GlyphIcons;
32
import javafx.beans.value.ObservableBooleanValue;
33
import javafx.event.ActionEvent;
34
import javafx.event.EventHandler;
3035
import javafx.scene.Node;
3136
import javafx.scene.control.MenuItem;
3237
3338
/**
3439
 * Represents a menu action that can generate {@link MenuItem} instances and
3540
 * and {@link Node} instances for a toolbar.
3641
 */
3742
public abstract class Action {
43
  /**
44
   * TODO: Reuse the {@link GenericBuilder}.
45
   *
46
   * @return The {@link Builder} for an instance of {@link Action}.
47
   */
48
  public static Builder builder() {
49
    return new Builder();
50
  }
51
3852
  public abstract MenuItem createMenuItem();
3953
...
4862
   */
4963
  public void addSubActions( Action... action ) {
64
  }
65
66
  /**
67
   * Provides a fluent interface around constructing actions so that duplication
68
   * can be avoided.
69
   */
70
  public static class Builder {
71
    private String mText;
72
    private String mAccelerator;
73
    private GlyphIcons mIcon;
74
    private EventHandler<ActionEvent> mAction;
75
    private ObservableBooleanValue mDisabled;
76
77
    /**
78
     * Sets the action text based on a resource bundle key.
79
     *
80
     * @param key The key to look up in the {@link Messages}.
81
     * @return The corresponding value, or the key name if none found.
82
     */
83
    public Builder setText( final String key ) {
84
      mText = Messages.get( key, key );
85
      return this;
86
    }
87
88
    public Builder setAccelerator( final String accelerator ) {
89
      mAccelerator = accelerator;
90
      return this;
91
    }
92
93
    public Builder setIcon( final GlyphIcons icon ) {
94
      mIcon = icon;
95
      return this;
96
    }
97
98
    public Builder setAction( final EventHandler<ActionEvent> action ) {
99
      mAction = action;
100
      return this;
101
    }
102
103
    public Builder setDisabled( final ObservableBooleanValue disabled ) {
104
      mDisabled = disabled;
105
      return this;
106
    }
107
108
    public Action build() {
109
      return new MenuAction( mText, mAccelerator, mIcon, mAction, mDisabled );
110
    }
50111
  }
51112
}
D src/main/java/com/keenwrite/util/ActionBuilder.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 com.keenwrite.Messages;
31
import de.jensd.fx.glyphs.GlyphIcons;
32
import javafx.beans.value.ObservableBooleanValue;
33
import javafx.event.ActionEvent;
34
import javafx.event.EventHandler;
35
36
/**
37
 * Provides a fluent interface around constructing actions so that duplication
38
 * can be avoided.
39
 */
40
public class ActionBuilder {
41
  private String mText;
42
  private String mAccelerator;
43
  private GlyphIcons mIcon;
44
  private EventHandler<ActionEvent> mAction;
45
  private ObservableBooleanValue mDisable;
46
47
  /**
48
   * Sets the action text based on a resource bundle key.
49
   *
50
   * @param key The key to look up in the {@link Messages}.
51
   * @return The corresponding value, or the key name if none found.
52
   */
53
  public ActionBuilder setText( final String key ) {
54
    mText = Messages.get( key, key );
55
    return this;
56
  }
57
58
  public ActionBuilder setAccelerator( final String accelerator ) {
59
    mAccelerator = accelerator;
60
    return this;
61
  }
62
63
  public ActionBuilder setIcon( final GlyphIcons icon ) {
64
    mIcon = icon;
65
    return this;
66
  }
67
68
  public ActionBuilder setAction( final EventHandler<ActionEvent> action ) {
69
    mAction = action;
70
    return this;
71
  }
72
73
  public ActionBuilder setDisable( final ObservableBooleanValue disable ) {
74
    mDisable = disable;
75
    return this;
76
  }
77
78
  public Action build() {
79
    return new MenuAction( mText, mAccelerator, mIcon, mAction, mDisable );
80
  }
81
}
821
A src/main/java/com/keenwrite/util/GenericBuilder.java
1
package com.keenwrite.util;
2
3
import java.util.ArrayList;
4
import java.util.List;
5
import java.util.function.BiConsumer;
6
import java.util.function.Consumer;
7
import java.util.function.Function;
8
import java.util.function.Supplier;
9
10
/**
11
 * Responsible for constructing objects that would otherwise require
12
 * a long list of constructor parameters.
13
 * <p>
14
 * See <a href="https://stackoverflow.com/a/31754787/59087">source</a> for
15
 * details.
16
 * </p>
17
 *
18
 * @param <MT> The mutable definition for the type of object to build.
19
 * @param <IT> The immutable definition for the type of object to build.
20
 */
21
public class GenericBuilder<MT, IT> {
22
  /**
23
   * Provides the methods to use for setting object properties.
24
   */
25
  private final Supplier<MT> mMutable;
26
27
  /**
28
   * Calling {@link #build()} will instantiate the immutable instance using
29
   * the mutator.
30
   */
31
  private final Function<MT, IT> mImmutable;
32
33
  /**
34
   * Adds a modifier to call when building an instance.
35
   */
36
  private final List<Consumer<MT>> mModifiers = new ArrayList<>();
37
38
  /**
39
   * Constructs a new builder instance that is capable of populating values for
40
   * any type of object.
41
   *
42
   * @param mutator Provides methods to use for setting object properties.
43
   */
44
  protected GenericBuilder(
45
      final Supplier<MT> mutator, final Function<MT, IT> immutable ) {
46
    mMutable = mutator;
47
    mImmutable = immutable;
48
  }
49
50
  /**
51
   * Starting point for building an instance of a particular class.
52
   *
53
   * @param supplier Returns the instance to build.
54
   * @param <MT>     The type of class to build.
55
   * @return A new {@link GenericBuilder} capable of populating data for an
56
   * instance of the class provided by the {@link Supplier}.
57
   */
58
  public static <MT, IT> GenericBuilder<MT, IT> of(
59
      final Supplier<MT> supplier, final Function<MT, IT> immutable ) {
60
    return new GenericBuilder<>( supplier, immutable );
61
  }
62
63
  /**
64
   * Registers a new value with the builder.
65
   *
66
   * @param consumer Accepts a value to be set upon the built object.
67
   * @param value    The value to use when building.
68
   * @param <V>      The type of value used when building.
69
   * @return This {@link GenericBuilder} instance.
70
   */
71
  public <V> GenericBuilder<MT, IT> with(
72
      final BiConsumer<MT, V> consumer, final V value ) {
73
    mModifiers.add( instance -> consumer.accept( instance, value ) );
74
    return this;
75
  }
76
77
  /**
78
   * Instantiates then populates the immutable object to build.
79
   *
80
   * @return The newly built object.
81
   */
82
  public IT build() {
83
    final var value = mMutable.get();
84
    mModifiers.forEach( modifier -> modifier.accept( value ) );
85
    mModifiers.clear();
86
    return mImmutable.apply( value );
87
  }
88
}
189
A src/main/java/com/keenwrite/util/Pair.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 java.util.AbstractMap;
31
import java.util.Map;
32
33
/**
34
 * Convenience class for pairing two objects together; this is a synonym for
35
 * {@link Map.Entry}.
36
 *
37
 * @param <K> The type of key to store in this pair.
38
 * @param <V> The type of value to store in this pair.
39
 */
40
public class Pair<K, V> extends AbstractMap.SimpleImmutableEntry<K, V> {
41
  /**
42
   * Associates a new key-value pair.
43
   *
44
   * @param key   The key for this key-value pairing.
45
   * @param value The value for this key-value pairing.
46
   */
47
  public Pair( final K key, final V value ) {
48
    super( key, value );
49
  }
50
}
151
M src/main/java/com/keenwrite/util/ProtocolResolver.java
7676
   *
7777
   * @param file Determine the protocol for this file.
78
   * @return The protocol for the given file.
78
   * @return The protocol for the given file, or {@link ProtocolScheme#UNKNOWN}
79
   * if the protocol cannot be determined.
7980
   */
8081
  private static String getProtocol( final File file ) {
81
    String result;
82
8382
    try {
84
      result = file.toURI().toURL().getProtocol();
83
      return file.toURI().toURL().getProtocol();
8584
    } catch( final MalformedURLException ex ) {
86
      // Value guaranteed to avoid identification as a standard protocol.
87
      result = UNKNOWN.toString();
85
      // Return a protocol guaranteed to be undefined.
86
      return UNKNOWN.toString();
8887
    }
89
90
    return result;
9188
  }
9289
}
M src/main/resources/com/keenwrite/messages.properties
7171
7272
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
73
Main.status.error.def.blank=Move the caret to a word before inserting a definition.
74
Main.status.error.def.empty=Create a definition before inserting a definition.
75
Main.status.error.def.missing=No definition value found for ''{0}''.
73
Main.status.error.def.blank=Move the caret to a word before inserting a definition
74
Main.status.error.def.empty=Create a definition before inserting a definition
75
Main.status.error.def.missing=No definition value found for ''{0}''
7676
Main.status.error.r=Error with [{0}...]: {1}
7777
Main.status.error.file.missing=Not found: {0}
78
79
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
80
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
7881
7982
# ########################################################################
M src/main/resources/com/keenwrite/preview/webview.css
2424
}
2525
26
/* BLOCKS ***/
26
#caret {
27
  background: #fcfeff;
28
}
29
2730
p, blockquote, ul, ol, dl, table, pre {
2831
  margin: 1em 0;
2932
}
3033
31
/* HEADINGS ***/
3234
h1, h2, h3, h4, h5, h6 {
3335
  font-weight: bold;
...
128130
}
129131
130
/* CODE ***/
131132
pre, code, tt {
132133
  /* Must be bundled in JAR file. */
...
146147
147148
pre > code {
148
  /* Reset the padding. */
149149
  padding: 0;
150150
  border: none;
151151
  background: transparent;
152152
}
153153
154154
pre {
155155
  border: .125em solid #ccc;
156156
  overflow: auto;
157
  /* Assign the new padding, independently from previous. */
158157
  padding: .25em .5em;
159158
}
160159
161160
pre code, pre tt {
162161
  background-color: transparent;
163162
  border: none;
164163
}
165164
166
/* QUOTES ***/
167165
blockquote {
168166
  border-left: .25em solid #ccc;
...
179177
}
180178
181
/* HORIZONTAL RULES ***/
182179
hr {
183180
  clear: both;
...
190187
}
191188
192
/* TABLES ***/
193189
table {
194190
  width: 100%;
...
209205
}
210206
211
/* IMAGES ***/
212207
img {
213208
  max-width: 100%;
A src/test/java/com/keenwrite/r/PluralizeTest.java
1
package com.keenwrite.r;
2
3
import org.junit.jupiter.api.BeforeAll;
4
import org.junit.jupiter.api.Test;
5
6
import javax.script.ScriptEngine;
7
import javax.script.ScriptEngineManager;
8
import javax.script.ScriptException;
9
import java.util.Map;
10
11
import static java.lang.String.format;
12
import static java.util.Map.entry;
13
import static java.util.Map.ofEntries;
14
import static org.junit.jupiter.api.Assertions.assertEquals;
15
16
/**
17
 * Test that English pluralization rules produce expected values.
18
 */
19
public class PluralizeTest {
20
  private static final ScriptEngine ENGINE =
21
      (new ScriptEngineManager()).getEngineByName( "Renjin" );
22
23
  private static final Map<String, String> PLURAL_MAP = ofEntries(
24
      entry( "beef", "beefs" ),
25
      entry( "brother", "brothers" ),
26
      entry( "child", "children" ),
27
      entry( "cow", "cows" ),
28
      entry( "ephemeris", "ephemerides" ),
29
      entry( "genie", "genies" ),
30
      entry( "money", "moneys" ),
31
      entry( "mongoose", "mongooses" ),
32
      entry( "mythos", "mythoi" ),
33
      entry( "octopus", "octopuses" ),
34
      entry( "ox", "oxen" ),
35
      entry( "soliloquy", "soliloquies" ),
36
      entry( "trilby", "trilbys" ),
37
      entry( "wolf", "wolves" )
38
  );
39
40
  @BeforeAll
41
  static void setup() throws ScriptException {
42
    r( "setwd( 'R' );" );
43
    r( "source( 'pluralize.R' );" );
44
  }
45
46
  @Test
47
  @SuppressWarnings("UnnecessaryLocalVariable")
48
  public void test_Pluralize_SingularForms_PluralForms()
49
      throws ScriptException {
50
    for( final var key : PLURAL_MAP.keySet() ) {
51
      final var expectedSingular = key;
52
      final var expectedPlural = PLURAL_MAP.get( key );
53
      final var actualSingular = pluralize( key, 1 );
54
      final var actualPlural = pluralize( key, 2 );
55
56
      assertEquals( expectedSingular, actualSingular );
57
      assertEquals( expectedPlural, actualPlural );
58
    }
59
  }
60
61
  private String pluralize( final String word, final int count )
62
      throws ScriptException {
63
    return r( format( "pluralize( '%s', %d );", word, count ) ).toString();
64
  }
65
66
  private static Object r( final String code ) throws ScriptException {
67
    return ENGINE.eval( code );
68
  }
69
}
170