Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
A .idea/dictionaries/jarvisd.xml
1
1
<component name="ProjectDictionaryState">
2
  <dictionary name="jarvisd">
3
    <words>
4
      <w>blockquotes</w>
5
    </words>
6
  </dictionary>
7
</component>
M README.md
4040
See the following documents for more information:
4141
42
* [USAGE.md](USAGE.md) - how variable definitions and string interpolation work.
43
* [USAGE-R.md](USAGE-R.md) - how to call R functions in R Markdown documents.
42
* [USAGE.md](USAGE.md) -- Variable definitions and string interpolation
43
* [USAGE-R.md](USAGE-R.md) -- Call R functions within R Markdown documents
44
* [USAGE-SVG.md](USAGE-SVG.md) -- Fix known issues with displaying SVG files
4445
4546
## Future Features
M build.gradle
1919
2020
dependencies {
21
  implementation 'org.reactfx:reactfx:1.4.1'
2122
  implementation 'org.controlsfx:controlsfx:11.0.1'
2223
  implementation 'org.fxmisc.richtext:richtextfx:0.10.5'
M src/main/java/com/scrivenvar/AbstractFileFactory.java
3636
3737
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
38
import static com.scrivenvar.FileType.UNKNOWN;
3839
import static java.lang.String.format;
3940
...
7980
8081
    boolean found = false;
81
    FileType fileType = null;
82
    FileType fileType = UNKNOWN;
8283
8384
    while( keys.hasNext() && !found ) {
M src/main/java/com/scrivenvar/Constants.java
2929
3030
import com.scrivenvar.service.Settings;
31
import org.fxmisc.flowless.VirtualizedScrollPane;
3132
3233
/**
...
109110
   */
110111
  public static final String USER_DIRECTORY = System.getProperty( "user.dir" );
112
113
  /**
114
   * Used as the prefix for uniquely identifying HTML block elements, which
115
   * helps coordinate scrolling the preview pane to where the user is typing.
116
   */
117
  public static final String PARAGRAPH_ID_PREFIX = "p-";
118
119
  /**
120
   * The {@link VirtualizedScrollPane} does not provide a way to differentiate
121
   * between keyboard entry triggering a scroll event and a mouse event that
122
   * triggers scrolling. This value ensures that if a user has "just" pressed
123
   * a key that no synchronized scrolling will be attempted, despite a
124
   * scrolling event being fired.
125
   */
126
  public static final int KEYBOARD_SCROLL_DELAY = 750;
111127
}
112128
M src/main/java/com/scrivenvar/FileEditorTab.java
3737
import javafx.beans.property.SimpleBooleanProperty;
3838
import javafx.beans.value.ChangeListener;
39
import javafx.beans.value.ObservableValue;
40
import javafx.event.Event;
41
import javafx.event.EventHandler;
42
import javafx.event.EventType;
43
import javafx.scene.Node;
44
import javafx.scene.Scene;
45
import javafx.scene.control.Tab;
46
import javafx.scene.control.Tooltip;
47
import javafx.scene.text.Text;
48
import javafx.stage.Window;
49
import org.fxmisc.richtext.StyleClassedTextArea;
50
import org.fxmisc.richtext.model.TwoDimensional.Position;
51
import org.fxmisc.undo.UndoManager;
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.scrivenvar.Messages.get;
60
import static java.nio.charset.StandardCharsets.UTF_8;
61
import static java.util.Locale.ENGLISH;
62
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
63
64
/**
65
 * Editor for a single file.
66
 *
67
 * @author Karl Tauber and White Magic Software, Ltd.
68
 */
69
public final class FileEditorTab extends Tab {
70
71
  private final Notifier mNotifier = Services.load( Notifier.class );
72
  private final EditorPane mEditorPane = new MarkdownEditorPane();
73
74
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
75
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
76
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
77
78
  /**
79
   * Character encoding used by the file (or default encoding if none found).
80
   */
81
  private Charset mEncoding = UTF_8;
82
83
  /**
84
   * File to load into the editor.
85
   */
86
  private Path mPath;
87
88
  public FileEditorTab( final Path path ) {
89
    setPath( path );
90
91
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
92
93
    setOnSelectionChanged( e -> {
94
      if( isSelected() ) {
95
        Platform.runLater( this::activated );
96
      }
97
    } );
98
  }
99
100
  private void updateTab() {
101
    setText( getTabTitle() );
102
    setGraphic( getModifiedMark() );
103
    setTooltip( getTabTooltip() );
104
  }
105
106
  /**
107
   * Returns the base filename (without the directory names).
108
   *
109
   * @return The untitled text if the path hasn't been set.
110
   */
111
  private String getTabTitle() {
112
    return getPath().getFileName().toString();
113
  }
114
115
  /**
116
   * Returns the full filename represented by the path.
117
   *
118
   * @return The untitled text if the path hasn't been set.
119
   */
120
  private Tooltip getTabTooltip() {
121
    final Path filePath = getPath();
122
    return new Tooltip( filePath == null ? "" : filePath.toString() );
123
  }
124
125
  /**
126
   * Returns a marker to indicate whether the file has been modified.
127
   *
128
   * @return "*" when the file has changed; otherwise null.
129
   */
130
  private Text getModifiedMark() {
131
    return isModified() ? new Text( "*" ) : null;
132
  }
133
134
  /**
135
   * Called when the user switches tab.
136
   */
137
  private void activated() {
138
    // Tab is closed or no longer active.
139
    if( getTabPane() == null || !isSelected() ) {
140
      return;
141
    }
142
143
    // Switch to the tab without loading if the contents are already in memory.
144
    if( getContent() != null ) {
145
      getEditorPane().requestFocus();
146
      return;
147
    }
148
149
    // Load the text and update the preview before the undo manager.
150
    load();
151
152
    // Track undo requests -- can only be called *after* load.
153
    initUndoManager();
154
    initLayout();
155
    initFocus();
156
  }
157
158
  private void initLayout() {
159
    setContent( getScrollPane() );
160
  }
161
162
  private Node getScrollPane() {
163
    return getEditorPane().getScrollPane();
164
  }
165
166
  private void initFocus() {
167
    getEditorPane().requestFocus();
168
  }
169
170
  private void initUndoManager() {
171
    final UndoManager<?> undoManager = getUndoManager();
172
    undoManager.forgetHistory();
173
174
    // Bind the editor undo manager to the properties.
175
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
176
    canUndo.bind( undoManager.undoAvailableProperty() );
177
    canRedo.bind( undoManager.redoAvailableProperty() );
178
  }
179
180
  /**
181
   * Searches from the caret position forward for the given string.
182
   *
183
   * @param needle The text string to match.
184
   */
185
  public void searchNext( final String needle ) {
186
    final String haystack = getEditorText();
187
    int index = haystack.indexOf( needle, getCaretPosition() );
188
189
    // Wrap around.
190
    if( index == -1 ) {
191
      index = haystack.indexOf( needle );
192
    }
193
194
    if( index >= 0 ) {
195
      setCaretPosition( index );
196
      getEditor().selectRange( index, index + needle.length() );
197
    }
198
  }
199
200
  /**
201
   * Returns the index into the text where the caret blinks happily away.
202
   *
203
   * @return A number from 0 to the editor's document text length.
204
   */
205
  public int getCaretPosition() {
206
    return getEditor().getCaretPosition();
207
  }
208
209
  /**
210
   * Moves the caret to a given offset.
211
   *
212
   * @param offset The new caret offset.
213
   */
214
  private void setCaretPosition( final int offset ) {
215
    getEditor().moveTo( offset );
216
    getEditor().requestFollowCaret();
217
  }
218
219
  /**
220
   * Returns the caret's current row and column position.
221
   *
222
   * @return The caret's offset into the document.
223
   */
224
  public Position getCaretOffset() {
225
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
226
  }
227
228
  /**
229
   * Allows observers to synchronize caret position changes.
230
   *
231
   * @return An observable caret property value.
232
   */
233
  public final ObservableValue<Integer> caretPositionProperty() {
234
    return getEditor().caretPositionProperty();
235
  }
236
237
  /**
238
   * Returns the text area associated with this tab.
239
   *
240
   * @return A text editor.
241
   */
242
  private StyleClassedTextArea getEditor() {
243
    return getEditorPane().getEditor();
244
  }
245
246
  /**
247
   * Returns true if the given path exactly matches this tab's path.
248
   *
249
   * @param check The path to compare against.
250
   * @return true The paths are the same.
251
   */
252
  public boolean isPath( final Path check ) {
253
    final Path filePath = getPath();
254
255
    return filePath != null && filePath.equals( check );
256
  }
257
258
  /**
259
   * Reads the entire file contents from the path associated with this tab.
260
   */
261
  private void load() {
262
    final Path path = getPath();
263
    final File file = path.toFile();
264
265
    try {
266
      if( file.exists() ) {
267
        if( file.canWrite() && file.canRead() ) {
268
          final EditorPane pane = getEditorPane();
269
          pane.setText( asString( Files.readAllBytes( path ) ) );
270
          pane.scrollToTop();
271
        }
272
        else {
273
          final String msg = get(
274
              "FileEditor.loadFailed.message",
275
              file.toString(),
276
              get( "FileEditor.loadFailed.reason.permissions" )
277
          );
278
          getNotifier().notify( msg );
279
        }
280
      }
281
    } catch( final Exception ex ) {
282
      getNotifier().notify( ex );
283
    }
284
  }
285
286
  /**
287
   * Saves the entire file contents from the path associated with this tab.
288
   *
289
   * @return true The file has been saved.
290
   */
291
  public boolean save() {
292
    try {
293
      final EditorPane editor = getEditorPane();
294
      Files.write( getPath(), asBytes( editor.getText() ) );
295
      editor.getUndoManager().mark();
296
      return true;
297
    } catch( final Exception ex ) {
298
      return alert(
299
          "FileEditor.saveFailed.title",
300
          "FileEditor.saveFailed.message",
301
          ex
302
      );
303
    }
304
  }
305
306
  /**
307
   * Creates an alert dialog and waits for it to close.
308
   *
309
   * @param titleKey   Resource bundle key for the alert dialog title.
310
   * @param messageKey Resource bundle key for the alert dialog message.
311
   * @param e          The unexpected happening.
312
   * @return false
313
   */
314
  @SuppressWarnings("SameParameterValue")
315
  private boolean alert(
316
      final String titleKey, final String messageKey, final Exception e ) {
317
    final Notifier service = getNotifier();
318
    final Path filePath = getPath();
319
320
    final Notification message = service.createNotification(
321
        get( titleKey ),
322
        get( messageKey ),
323
        filePath == null ? "" : filePath,
324
        e.getMessage()
325
    );
326
327
    try {
328
      service.createError( getWindow(), message ).showAndWait();
329
    } catch( final Exception ex ) {
330
      getNotifier().notify( ex );
331
    }
332
333
    return false;
334
  }
335
336
  private Window getWindow() {
337
    final Scene scene = getEditorPane().getScene();
338
339
    if( scene == null ) {
340
      throw new UnsupportedOperationException( "No scene window available" );
341
    }
342
343
    return scene.getWindow();
344
  }
345
346
  /**
347
   * Returns a best guess at the file encoding. If the encoding could not be
348
   * detected, this will return the default charset for the JVM.
349
   *
350
   * @param bytes The bytes to perform character encoding detection.
351
   * @return The character encoding.
352
   */
353
  private Charset detectEncoding( final byte[] bytes ) {
354
    final var detector = new UniversalDetector( null );
355
    detector.handleData( bytes, 0, bytes.length );
356
    detector.dataEnd();
357
358
    final String charset = detector.getDetectedCharset();
359
360
    return charset == null
361
        ? Charset.defaultCharset()
362
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
363
  }
364
365
  /**
366
   * Converts the given string to an array of bytes using the encoding that was
367
   * originally detected (if any) and associated with this file.
368
   *
369
   * @param text The text to convert into the original file encoding.
370
   * @return A series of bytes ready for writing to a file.
371
   */
372
  private byte[] asBytes( final String text ) {
373
    return text.getBytes( getEncoding() );
374
  }
375
376
  /**
377
   * Converts the given bytes into a Java String. This will call setEncoding
378
   * with the encoding detected by the CharsetDetector.
379
   *
380
   * @param text The text of unknown character encoding.
381
   * @return The text, in its auto-detected encoding, as a String.
382
   */
383
  private String asString( final byte[] text ) {
384
    setEncoding( detectEncoding( text ) );
385
    return new String( text, getEncoding() );
386
  }
387
388
  /**
389
   * Returns the path to the file being edited in this tab.
390
   *
391
   * @return A non-null instance.
392
   */
393
  public Path getPath() {
394
    return mPath;
395
  }
396
397
  /**
398
   * Sets the path to a file for editing and then updates the tab with the
399
   * file contents.
400
   *
401
   * @param path A non-null instance.
402
   */
403
  public void setPath( final Path path ) {
404
    assert path != null;
405
406
    mPath = path;
407
408
    updateTab();
409
  }
410
411
  public boolean isModified() {
412
    return mModified.get();
413
  }
414
415
  ReadOnlyBooleanProperty modifiedProperty() {
416
    return mModified.getReadOnlyProperty();
417
  }
418
419
  BooleanProperty canUndoProperty() {
420
    return this.canUndo;
421
  }
422
423
  BooleanProperty canRedoProperty() {
424
    return this.canRedo;
425
  }
426
427
  private UndoManager<?> getUndoManager() {
428
    return getEditorPane().getUndoManager();
429
  }
430
431
  /**
432
   * Forwards to the editor pane's listeners for text change events.
433
   *
434
   * @param listener The listener to notify when the text changes.
435
   */
436
  public void addTextChangeListener( final ChangeListener<String> listener ) {
437
    getEditorPane().addTextChangeListener( listener );
438
  }
439
440
  /**
441
   * Forwards to the editor pane's listeners for caret paragraph change events.
442
   *
443
   * @param listener The listener to notify when the caret changes paragraphs.
444
   */
445
  public void addCaretParagraphListener(
446
      final ChangeListener<Integer> listener ) {
447
    getEditorPane().addCaretParagraphListener( listener );
448
  }
449
450
  public <T extends Event> void addEventFilter(
451
      final EventType<T> eventType,
452
      final EventHandler<? super T> eventFilter ) {
453
    getEditorPane().getEditor().addEventFilter( eventType, eventFilter );
454
  }
455
456
  /**
457
   * Forwards the request to the editor pane.
458
   *
459
   * @return The text to process.
460
   */
461
  public String getEditorText() {
462
    return getEditorPane().getText();
463
  }
464
465
  /**
466
   * Returns the editor pane, or creates one if it doesn't yet exist.
467
   *
468
   * @return The editor pane, never null.
469
   */
470
  public EditorPane getEditorPane() {
471
    return mEditorPane;
472
  }
473
474
  /**
475
   * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been
476
   * determined.
477
   *
478
   * @return The file encoding or UTF-8 if unknown.
479
   */
480
  private Charset getEncoding() {
481
    return mEncoding;
482
  }
483
484
  private void setEncoding( final Charset encoding ) {
485
    assert encoding != null;
486
39
import javafx.event.Event;
40
import javafx.event.EventHandler;
41
import javafx.event.EventType;
42
import javafx.scene.Node;
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.richtext.StyleClassedTextArea;
49
import org.fxmisc.undo.UndoManager;
50
import org.jetbrains.annotations.NotNull;
51
import org.mozilla.universalchardet.UniversalDetector;
52
53
import java.io.File;
54
import java.nio.charset.Charset;
55
import java.nio.file.Files;
56
import java.nio.file.Path;
57
58
import static com.scrivenvar.Messages.get;
59
import static java.nio.charset.StandardCharsets.UTF_8;
60
import static java.util.Locale.ENGLISH;
61
62
/**
63
 * Editor for a single file.
64
 *
65
 * @author Karl Tauber and White Magic Software, Ltd.
66
 */
67
public final class FileEditorTab extends Tab {
68
69
  private final Notifier mNotifier = Services.load( Notifier.class );
70
  private final MarkdownEditorPane mEditorPane = new MarkdownEditorPane();
71
72
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
73
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
74
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
75
76
  /**
77
   * Character encoding used by the file (or default encoding if none found).
78
   */
79
  private Charset mEncoding = UTF_8;
80
81
  /**
82
   * File to load into the editor.
83
   */
84
  private Path mPath;
85
86
  public FileEditorTab( final Path path ) {
87
    setPath( path );
88
89
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
90
91
    setOnSelectionChanged( e -> {
92
      if( isSelected() ) {
93
        Platform.runLater( this::activated );
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
    // Switch to the tab without loading if the contents are already in memory.
142
    if( getContent() != null ) {
143
      getEditorPane().requestFocus();
144
      return;
145
    }
146
147
    // Load the text and update the preview before the undo manager.
148
    load();
149
150
    // Track undo requests -- can only be called *after* load.
151
    initUndoManager();
152
    initLayout();
153
    initFocus();
154
  }
155
156
  private void initLayout() {
157
    setContent( getScrollPane() );
158
  }
159
160
  private Node getScrollPane() {
161
    return getEditorPane().getScrollPane();
162
  }
163
164
  private void initFocus() {
165
    getEditorPane().requestFocus();
166
  }
167
168
  private void initUndoManager() {
169
    final UndoManager<?> undoManager = getUndoManager();
170
    undoManager.forgetHistory();
171
172
    // Bind the editor undo manager to the properties.
173
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
174
    canUndo.bind( undoManager.undoAvailableProperty() );
175
    canRedo.bind( undoManager.redoAvailableProperty() );
176
  }
177
178
  /**
179
   * Searches from the caret position forward for the given string.
180
   *
181
   * @param needle The text string to match.
182
   */
183
  public void searchNext( final String needle ) {
184
    final String haystack = getEditorText();
185
    int index = haystack.indexOf( needle, getCaretPosition() );
186
187
    // Wrap around.
188
    if( index == -1 ) {
189
      index = haystack.indexOf( needle );
190
    }
191
192
    if( index >= 0 ) {
193
      setCaretPosition( index );
194
      getEditor().selectRange( index, index + needle.length() );
195
    }
196
  }
197
198
  /**
199
   * Returns the index into the text where the caret blinks happily away.
200
   *
201
   * @return A number from 0 to the editor's document text length.
202
   */
203
  public int getCaretPosition() {
204
    return getEditor().getCaretPosition();
205
  }
206
207
  /**
208
   * Moves the caret to a given offset.
209
   *
210
   * @param offset The new caret offset.
211
   */
212
  private void setCaretPosition( final int offset ) {
213
    getEditor().moveTo( offset );
214
    getEditor().requestFollowCaret();
215
  }
216
217
  /**
218
   * Returns the text area associated with this tab.
219
   *
220
   * @return A text editor.
221
   */
222
  private StyleClassedTextArea getEditor() {
223
    return getEditorPane().getEditor();
224
  }
225
226
  /**
227
   * Returns true if the given path exactly matches this tab's path.
228
   *
229
   * @param check The path to compare against.
230
   * @return true The paths are the same.
231
   */
232
  public boolean isPath( final Path check ) {
233
    final Path filePath = getPath();
234
235
    return filePath != null && filePath.equals( check );
236
  }
237
238
  /**
239
   * Reads the entire file contents from the path associated with this tab.
240
   */
241
  private void load() {
242
    final Path path = getPath();
243
    final File file = path.toFile();
244
245
    try {
246
      if( file.exists() ) {
247
        if( file.canWrite() && file.canRead() ) {
248
          final EditorPane pane = getEditorPane();
249
          pane.setText( asString( Files.readAllBytes( path ) ) );
250
          pane.scrollToTop();
251
        }
252
        else {
253
          final String msg = get(
254
              "FileEditor.loadFailed.message",
255
              file.toString(),
256
              get( "FileEditor.loadFailed.reason.permissions" )
257
          );
258
          getNotifier().notify( msg );
259
        }
260
      }
261
    } catch( final Exception ex ) {
262
      getNotifier().notify( ex );
263
    }
264
  }
265
266
  /**
267
   * Saves the entire file contents from the path associated with this tab.
268
   *
269
   * @return true The file has been saved.
270
   */
271
  public boolean save() {
272
    try {
273
      final EditorPane editor = getEditorPane();
274
      Files.write( getPath(), asBytes( editor.getText() ) );
275
      editor.getUndoManager().mark();
276
      return true;
277
    } catch( final Exception ex ) {
278
      return alert(
279
          "FileEditor.saveFailed.title",
280
          "FileEditor.saveFailed.message",
281
          ex
282
      );
283
    }
284
  }
285
286
  /**
287
   * Creates an alert dialog and waits for it to close.
288
   *
289
   * @param titleKey   Resource bundle key for the alert dialog title.
290
   * @param messageKey Resource bundle key for the alert dialog message.
291
   * @param e          The unexpected happening.
292
   * @return false
293
   */
294
  @SuppressWarnings("SameParameterValue")
295
  private boolean alert(
296
      final String titleKey, final String messageKey, final Exception e ) {
297
    final Notifier service = getNotifier();
298
    final Path filePath = getPath();
299
300
    final Notification message = service.createNotification(
301
        get( titleKey ),
302
        get( messageKey ),
303
        filePath == null ? "" : filePath,
304
        e.getMessage()
305
    );
306
307
    try {
308
      service.createError( getWindow(), message ).showAndWait();
309
    } catch( final Exception ex ) {
310
      getNotifier().notify( ex );
311
    }
312
313
    return false;
314
  }
315
316
  private Window getWindow() {
317
    final Scene scene = getEditorPane().getScene();
318
319
    if( scene == null ) {
320
      throw new UnsupportedOperationException( "No scene window available" );
321
    }
322
323
    return scene.getWindow();
324
  }
325
326
  /**
327
   * Returns a best guess at the file encoding. If the encoding could not be
328
   * detected, this will return the default charset for the JVM.
329
   *
330
   * @param bytes The bytes to perform character encoding detection.
331
   * @return The character encoding.
332
   */
333
  private Charset detectEncoding( final byte[] bytes ) {
334
    final var detector = new UniversalDetector( null );
335
    detector.handleData( bytes, 0, bytes.length );
336
    detector.dataEnd();
337
338
    final String charset = detector.getDetectedCharset();
339
340
    return charset == null
341
        ? Charset.defaultCharset()
342
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
343
  }
344
345
  /**
346
   * Converts the given string to an array of bytes using the encoding that was
347
   * originally detected (if any) and associated with this file.
348
   *
349
   * @param text The text to convert into the original file encoding.
350
   * @return A series of bytes ready for writing to a file.
351
   */
352
  private byte[] asBytes( final String text ) {
353
    return text.getBytes( getEncoding() );
354
  }
355
356
  /**
357
   * Converts the given bytes into a Java String. This will call setEncoding
358
   * with the encoding detected by the CharsetDetector.
359
   *
360
   * @param text The text of unknown character encoding.
361
   * @return The text, in its auto-detected encoding, as a String.
362
   */
363
  private String asString( final byte[] text ) {
364
    setEncoding( detectEncoding( text ) );
365
    return new String( text, getEncoding() );
366
  }
367
368
  /**
369
   * Returns the path to the file being edited in this tab.
370
   *
371
   * @return A non-null instance.
372
   */
373
  public Path getPath() {
374
    return mPath;
375
  }
376
377
  /**
378
   * Sets the path to a file for editing and then updates the tab with the
379
   * file contents.
380
   *
381
   * @param path A non-null instance.
382
   */
383
  public void setPath( final Path path ) {
384
    assert path != null;
385
    mPath = path;
386
387
    updateTab();
388
  }
389
390
  public boolean isModified() {
391
    return mModified.get();
392
  }
393
394
  ReadOnlyBooleanProperty modifiedProperty() {
395
    return mModified.getReadOnlyProperty();
396
  }
397
398
  BooleanProperty canUndoProperty() {
399
    return this.canUndo;
400
  }
401
402
  BooleanProperty canRedoProperty() {
403
    return this.canRedo;
404
  }
405
406
  private UndoManager<?> getUndoManager() {
407
    return getEditorPane().getUndoManager();
408
  }
409
410
  /**
411
   * Forwards to the editor pane's listeners for text change events.
412
   *
413
   * @param listener The listener to notify when the text changes.
414
   */
415
  public void addTextChangeListener( final ChangeListener<String> listener ) {
416
    getEditorPane().addTextChangeListener( listener );
417
  }
418
419
  /**
420
   * Forwards to the editor pane's listeners for caret change events.
421
   *
422
   * @param listener Notified when the caret position changes.
423
   */
424
  public void addCaretPositionListener(
425
      final ChangeListener<? super Integer> listener ) {
426
    getEditorPane().addCaretPositionListener( listener );
427
  }
428
429
  /**
430
   * Forwards to the editor pane's listeners for paragraph index change events.
431
   *
432
   * @param listener Notified when the caret's paragraph index changes.
433
   */
434
  public void addCaretParagraphListener(
435
      final ChangeListener<? super Integer> listener ) {
436
    getEditorPane().addCaretParagraphListener( listener );
437
  }
438
439
  public <T extends Event> void addEventFilter(
440
      final EventType<T> eventType,
441
      final EventHandler<? super T> eventFilter ) {
442
    getEditorPane().getEditor().addEventFilter( eventType, eventFilter );
443
  }
444
445
  /**
446
   * Forwards the request to the editor pane.
447
   *
448
   * @return The text to process.
449
   */
450
  public String getEditorText() {
451
    return getEditorPane().getText();
452
  }
453
454
  /**
455
   * Returns the editor pane, or creates one if it doesn't yet exist.
456
   *
457
   * @return The editor pane, never null.
458
   */
459
  @NotNull
460
  public MarkdownEditorPane getEditorPane() {
461
    return mEditorPane;
462
  }
463
464
  /**
465
   * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been
466
   * determined.
467
   *
468
   * @return The file encoding or UTF-8 if unknown.
469
   */
470
  private Charset getEncoding() {
471
    return mEncoding;
472
  }
473
474
  private void setEncoding( final Charset encoding ) {
475
    assert encoding != null;
487476
    mEncoding = encoding;
488477
  }
M src/main/java/com/scrivenvar/FileEditorTabPane.java
3939
import javafx.beans.property.ReadOnlyObjectWrapper;
4040
import javafx.beans.value.ChangeListener;
41
import javafx.beans.value.ObservableValue;
42
import javafx.collections.ListChangeListener;
43
import javafx.collections.ObservableList;
44
import javafx.event.Event;
45
import javafx.scene.Node;
46
import javafx.scene.control.Alert;
47
import javafx.scene.control.ButtonType;
48
import javafx.scene.control.Tab;
49
import javafx.scene.control.TabPane;
50
import javafx.stage.FileChooser;
51
import javafx.stage.FileChooser.ExtensionFilter;
52
import javafx.stage.Window;
53
54
import java.io.File;
55
import java.nio.file.Path;
56
import java.util.ArrayList;
57
import java.util.List;
58
import java.util.Optional;
59
import java.util.concurrent.atomic.AtomicReference;
60
import java.util.function.Consumer;
61
import java.util.prefs.Preferences;
62
import java.util.stream.Collectors;
63
64
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
65
import static com.scrivenvar.FileType.*;
66
import static com.scrivenvar.Messages.get;
67
import static com.scrivenvar.service.events.Notifier.YES;
68
69
/**
70
 * Tab pane for file editors.
71
 *
72
 * @author Karl Tauber and White Magic Software, Ltd.
73
 */
74
public final class FileEditorTabPane extends TabPane {
75
76
  private final static String FILTER_EXTENSION_TITLES =
77
      "Dialog.file.choose.filter";
78
79
  private final static Options sOptions = Services.load( Options.class );
80
  private final static Settings sSettings = Services.load( Settings.class );
81
  private final static Notifier sNotifier = Services.load( Notifier.class );
82
83
  private final ReadOnlyObjectWrapper<Path> openDefinition =
84
      new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
86
      new ReadOnlyObjectWrapper<>();
87
  private final ReadOnlyBooleanWrapper anyFileEditorModified =
88
      new ReadOnlyBooleanWrapper();
89
  private final Consumer<Double> mScrollEventObserver;
90
  private final ChangeListener<Integer> mCaretListener;
91
92
  /**
93
   * Constructs a new file editor tab pane.
94
   */
95
  public FileEditorTabPane(
96
      final Consumer<Double> scrollEventObserver,
97
      final ChangeListener<Integer> caretListener ) {
98
    final ObservableList<Tab> tabs = getTabs();
99
100
    setFocusTraversable( false );
101
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
102
103
    addTabSelectionListener(
104
        ( ObservableValue<? extends Tab> tabPane,
105
          final Tab oldTab, final Tab newTab ) -> {
106
107
          if( newTab != null ) {
108
            mActiveFileEditor.set( (FileEditorTab) newTab );
109
          }
110
        }
111
    );
112
113
    final ChangeListener<Boolean> modifiedListener =
114
        ( observable, oldValue, newValue ) -> {
115
          for( final Tab tab : tabs ) {
116
            if( ((FileEditorTab) tab).isModified() ) {
117
              this.anyFileEditorModified.set( true );
118
              break;
119
            }
120
          }
121
        };
122
123
    tabs.addListener(
124
        (ListChangeListener<Tab>) change -> {
125
          while( change.next() ) {
126
            if( change.wasAdded() ) {
127
              change.getAddedSubList().forEach(
128
                  ( tab ) ->
129
                      ((FileEditorTab) tab).modifiedProperty()
130
                                           .addListener( modifiedListener ) );
131
            }
132
            else if( change.wasRemoved() ) {
133
              change.getRemoved().forEach(
134
                  ( tab ) ->
135
                      ((FileEditorTab) tab).modifiedProperty()
136
                                           .removeListener( modifiedListener ) );
137
            }
138
          }
139
140
          // Changes in the tabs may also change anyFileEditorModified property
141
          // (e.g. closed modified file)
142
          modifiedListener.changed( null, null, null );
143
        }
144
    );
145
146
    mScrollEventObserver = scrollEventObserver;
147
    mCaretListener = caretListener;
148
  }
149
150
  /**
151
   * Allows observers to be notified when the current file editor tab changes.
152
   *
153
   * @param listener The listener to notify of tab change events.
154
   */
155
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
156
    // Observe the tab so that when a new tab is opened or selected,
157
    // a notification is kicked off.
158
    getSelectionModel().selectedItemProperty().addListener( listener );
159
  }
160
161
  /**
162
   * Returns the tab that has keyboard focus.
163
   *
164
   * @return A non-null instance.
165
   */
166
  public FileEditorTab getActiveFileEditor() {
167
    return mActiveFileEditor.get();
168
  }
169
170
  /**
171
   * Returns the property corresponding to the tab that has focus.
172
   *
173
   * @return A non-null instance.
174
   */
175
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
176
    return mActiveFileEditor.getReadOnlyProperty();
177
  }
178
179
  /**
180
   * Property that can answer whether the text has been modified.
181
   *
182
   * @return A non-null instance, true meaning the content has not been saved.
183
   */
184
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
185
    return this.anyFileEditorModified.getReadOnlyProperty();
186
  }
187
188
  /**
189
   * Creates a new editor instance from the given path.
190
   *
191
   * @param path The file to open.
192
   * @return A non-null instance.
193
   */
194
  private FileEditorTab createFileEditor( final Path path ) {
195
    assert path != null;
196
197
    final FileEditorTab tab = new FileEditorTab( path );
198
199
    tab.getEditorPane().getScrollPane().estimatedScrollYProperty().addObserver(
200
        mScrollEventObserver
201
    );
202
203
    tab.setOnCloseRequest( e -> {
204
      if( !canCloseEditor( tab ) ) {
205
        e.consume();
206
      }
207
      else if( isActiveFileEditor( tab ) ) {
208
        // Prevent prompting the user to save when there are no file editor
209
        // tabs open.
210
        mActiveFileEditor.set( null );
211
      }
212
    } );
213
214
    tab.addCaretParagraphListener( mCaretListener );
215
216
    return tab;
217
  }
218
219
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
220
    return getActiveFileEditor() == tab;
221
  }
222
223
  private Path getDefaultPath() {
224
    final String filename = getDefaultFilename();
225
    return (new File( filename )).toPath();
226
  }
227
228
  private String getDefaultFilename() {
229
    return getSettings().getSetting( "file.default", "untitled.md" );
230
  }
231
232
  /**
233
   * Called when the user selects New from the File menu.
234
   */
235
  void newEditor() {
236
    final Path defaultPath = getDefaultPath();
237
    final FileEditorTab tab = createFileEditor( defaultPath );
238
239
    getTabs().add( tab );
240
    getSelectionModel().select( tab );
241
  }
242
243
  void openFileDialog() {
244
    final String title = get( "Dialog.file.choose.open.title" );
245
    final FileChooser dialog = createFileChooser( title );
246
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
247
248
    if( files != null ) {
249
      openFiles( files );
250
    }
251
  }
252
253
  /**
254
   * Opens the files into new editors, unless one of those files was a
255
   * definition file. The definition file is loaded into the definition pane,
256
   * but only the first one selected (multiple definition files will result in a
257
   * warning).
258
   *
259
   * @param files The list of non-definition files that the were requested to
260
   *              open.
261
   */
262
  private void openFiles( final List<File> files ) {
263
    final List<String> extensions =
264
        createExtensionFilter( DEFINITION ).getExtensions();
265
    final FileTypePredicate predicate =
266
        new FileTypePredicate( extensions );
267
268
    // The user might have opened multiple definitions files. These will
269
    // be discarded from the text editable files.
270
    final List<File> definitions
271
        = files.stream().filter( predicate ).collect( Collectors.toList() );
272
273
    // Create a modifiable list to remove any definition files that were
274
    // opened.
275
    final List<File> editors = new ArrayList<>( files );
276
277
    if( !editors.isEmpty() ) {
278
      saveLastDirectory( editors.get( 0 ) );
279
    }
280
281
    editors.removeAll( definitions );
282
283
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
284
    if( !editors.isEmpty() ) {
285
      openEditors( editors, 0 );
286
    }
287
288
    if( !definitions.isEmpty() ) {
289
      openDefinition( definitions.get( 0 ) );
290
    }
291
  }
292
293
  private void openEditors( final List<File> files, final int activeIndex ) {
294
    final int fileTally = files.size();
295
    final List<Tab> tabs = getTabs();
296
297
    // Close single unmodified "Untitled" tab.
298
    if( tabs.size() == 1 ) {
299
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
300
301
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
302
        closeEditor( fileEditor, false );
303
      }
304
    }
305
306
    for( int i = 0; i < fileTally; i++ ) {
307
      final Path path = files.get( i ).toPath();
308
309
      FileEditorTab fileEditorTab = findEditor( path );
310
311
      // Only open new files.
312
      if( fileEditorTab == null ) {
313
        fileEditorTab = createFileEditor( path );
314
        getTabs().add( fileEditorTab );
315
      }
316
317
      // Select the first file in the list.
318
      if( i == activeIndex ) {
319
        getSelectionModel().select( fileEditorTab );
320
      }
321
    }
322
  }
323
324
  /**
325
   * Returns a property that changes when a new definition file is opened.
326
   *
327
   * @return The path to a definition file that was opened.
328
   */
329
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
330
    return getOnOpenDefinitionFile().getReadOnlyProperty();
331
  }
332
333
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
334
    return this.openDefinition;
335
  }
336
337
  /**
338
   * Called when the user has opened a definition file (using the file open
339
   * dialog box). This will replace the current set of definitions for the
340
   * active tab.
341
   *
342
   * @param definition The file to open.
343
   */
344
  private void openDefinition( final File definition ) {
345
    // TODO: Prevent reading this file twice when a new text document is opened.
346
    // (might be a matter of checking the value first).
347
    getOnOpenDefinitionFile().set( definition.toPath() );
348
  }
349
350
  /**
351
   * Called when the contents of the editor are to be saved.
352
   *
353
   * @param tab The tab containing content to save.
354
   * @return true The contents were saved (or needn't be saved).
355
   */
356
  public boolean saveEditor( final FileEditorTab tab ) {
357
    if( tab == null || !tab.isModified() ) {
358
      return true;
359
    }
360
361
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
362
  }
363
364
  /**
365
   * Opens the Save As dialog for the user to save the content under a new
366
   * path.
367
   *
368
   * @param tab The tab with contents to save.
369
   * @return true The contents were saved, or the tab was null.
370
   */
371
  public boolean saveEditorAs( final FileEditorTab tab ) {
372
    if( tab == null ) {
373
      return true;
374
    }
375
376
    getSelectionModel().select( tab );
377
378
    final FileChooser fileChooser = createFileChooser( get(
379
        "Dialog.file.choose.save.title" ) );
380
    final File file = fileChooser.showSaveDialog( getWindow() );
381
    if( file == null ) {
382
      return false;
383
    }
384
385
    saveLastDirectory( file );
386
    tab.setPath( file.toPath() );
387
388
    return tab.save();
389
  }
390
391
  void saveAllEditors() {
392
    for( final FileEditorTab fileEditor : getAllEditors() ) {
393
      saveEditor( fileEditor );
394
    }
395
  }
396
397
  /**
398
   * Answers whether the file has had modifications. '
399
   *
400
   * @param tab THe tab to check for modifications.
401
   * @return false The file is unmodified.
402
   */
403
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
404
  boolean canCloseEditor( final FileEditorTab tab ) {
405
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
406
    canClose.set( true );
407
408
    if( tab.isModified() ) {
409
      final Notification message = getNotifyService().createNotification(
410
          Messages.get( "Alert.file.close.title" ),
411
          Messages.get( "Alert.file.close.text" ),
412
          tab.getText()
413
      );
414
415
      final Alert confirmSave = getNotifyService().createConfirmation(
416
          getWindow(), message );
417
418
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
419
420
      buttonType.ifPresent(
421
          save -> canClose.set(
422
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
423
          )
424
      );
425
    }
426
427
    return canClose.get();
428
  }
429
430
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
431
    if( tab == null ) {
432
      return true;
433
    }
434
435
    if( save ) {
436
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
437
      Event.fireEvent( tab, event );
438
439
      if( event.isConsumed() ) {
440
        return false;
441
      }
442
    }
443
444
    getTabs().remove( tab );
445
446
    if( tab.getOnClosed() != null ) {
447
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
448
    }
449
450
    return true;
451
  }
452
453
  boolean closeAllEditors() {
454
    final FileEditorTab[] allEditors = getAllEditors();
455
    final FileEditorTab activeEditor = getActiveFileEditor();
456
457
    // try to save active tab first because in case the user decides to cancel,
458
    // then it stays active
459
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
460
      return false;
461
    }
462
463
    // This should be called any time a tab changes.
464
    persistPreferences();
465
466
    // save modified tabs
467
    for( int i = 0; i < allEditors.length; i++ ) {
468
      final FileEditorTab fileEditor = allEditors[ i ];
469
470
      if( fileEditor == activeEditor ) {
471
        continue;
472
      }
473
474
      if( fileEditor.isModified() ) {
475
        // activate the modified tab to make its modified content visible to
476
        // the user
477
        getSelectionModel().select( i );
478
479
        if( !canCloseEditor( fileEditor ) ) {
480
          return false;
481
        }
482
      }
483
    }
484
485
    // Close all tabs.
486
    for( final FileEditorTab fileEditor : allEditors ) {
487
      if( !closeEditor( fileEditor, false ) ) {
488
        return false;
489
      }
490
    }
491
492
    return getTabs().isEmpty();
493
  }
494
495
  private FileEditorTab[] getAllEditors() {
496
    final ObservableList<Tab> tabs = getTabs();
497
    final int length = tabs.size();
498
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
499
500
    for( int i = 0; i < length; i++ ) {
501
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
502
    }
503
504
    return allEditors;
505
  }
506
507
  /**
508
   * Returns the file editor tab that has the given path.
509
   *
510
   * @return null No file editor tab for the given path was found.
511
   */
512
  private FileEditorTab findEditor( final Path path ) {
513
    for( final Tab tab : getTabs() ) {
514
      final FileEditorTab fileEditor = (FileEditorTab) tab;
515
516
      if( fileEditor.isPath( path ) ) {
517
        return fileEditor;
518
      }
519
    }
520
521
    return null;
522
  }
523
524
  private FileChooser createFileChooser( String title ) {
525
    final FileChooser fileChooser = new FileChooser();
526
527
    fileChooser.setTitle( title );
528
    fileChooser.getExtensionFilters().addAll(
529
        createExtensionFilters() );
530
531
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
532
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
533
534
    if( !file.isDirectory() ) {
535
      file = new File( "." );
536
    }
537
538
    fileChooser.setInitialDirectory( file );
539
    return fileChooser;
540
  }
541
542
  private List<ExtensionFilter> createExtensionFilters() {
543
    final List<ExtensionFilter> list = new ArrayList<>();
544
545
    // TODO: Return a list of all properties that match the filter prefix.
546
    // This will allow dynamic filters to be added and removed just by
547
    // updating the properties file.
548
    list.add( createExtensionFilter( ALL ) );
549
    list.add( createExtensionFilter( SOURCE ) );
550
    list.add( createExtensionFilter( DEFINITION ) );
551
    list.add( createExtensionFilter( XML ) );
552
    return list;
553
  }
554
555
  /**
556
   * Returns a filter for file name extensions recognized by the application
557
   * that can be opened by the user.
558
   *
559
   * @param filetype Used to find the globbing pattern for extensions.
560
   * @return A filename filter suitable for use by a FileDialog instance.
561
   */
562
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
563
    final String tKey = String.format( "%s.title.%s",
564
                                       FILTER_EXTENSION_TITLES,
565
                                       filetype );
566
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
567
568
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
569
  }
570
571
  private List<String> getExtensions( final String key ) {
572
    return getSettings().getStringSettingList( key );
573
  }
574
575
  private void saveLastDirectory( final File file ) {
576
    getPreferences().put( "lastDirectory", file.getParent() );
577
  }
578
579
  public void restorePreferences() {
580
    int activeIndex = 0;
581
582
    final Preferences preferences = getPreferences();
583
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
584
    final String activeFileName = preferences.get( "activeFile", null );
585
586
    final List<File> files = new ArrayList<>( fileNames.length );
587
588
    for( final String fileName : fileNames ) {
589
      final File file = new File( fileName );
590
591
      if( file.exists() ) {
592
        files.add( file );
593
594
        if( fileName.equals( activeFileName ) ) {
595
          activeIndex = files.size() - 1;
596
        }
597
      }
598
    }
599
600
    if( files.isEmpty() ) {
601
      newEditor();
602
    }
603
    else {
604
      openEditors( files, activeIndex );
605
    }
606
  }
607
608
  public void persistPreferences() {
609
    final ObservableList<Tab> allEditors = getTabs();
610
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
611
612
    for( final Tab tab : allEditors ) {
613
      final FileEditorTab fileEditor = (FileEditorTab) tab;
614
      final Path filePath = fileEditor.getPath();
615
616
      if( filePath != null ) {
617
        fileNames.add( filePath.toString() );
618
      }
619
    }
620
621
    final Preferences preferences = getPreferences();
622
    Utils.putPrefsStrings( preferences,
623
                           "file",
624
                           fileNames.toArray( new String[ 0 ] ) );
625
626
    final FileEditorTab activeEditor = getActiveFileEditor();
627
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
628
629
    if( filePath == null ) {
630
      preferences.remove( "activeFile" );
631
    }
632
    else {
633
      preferences.put( "activeFile", filePath.toString() );
634
    }
41
import javafx.collections.ListChangeListener;
42
import javafx.collections.ObservableList;
43
import javafx.event.Event;
44
import javafx.scene.Node;
45
import javafx.scene.control.Alert;
46
import javafx.scene.control.ButtonType;
47
import javafx.scene.control.Tab;
48
import javafx.scene.control.TabPane;
49
import javafx.stage.FileChooser;
50
import javafx.stage.FileChooser.ExtensionFilter;
51
import javafx.stage.Window;
52
53
import java.io.File;
54
import java.nio.file.Path;
55
import java.util.ArrayList;
56
import java.util.List;
57
import java.util.Optional;
58
import java.util.concurrent.atomic.AtomicReference;
59
import java.util.prefs.Preferences;
60
import java.util.stream.Collectors;
61
62
import static com.scrivenvar.Constants.GLOB_PREFIX_FILE;
63
import static com.scrivenvar.FileType.*;
64
import static com.scrivenvar.Messages.get;
65
import static com.scrivenvar.service.events.Notifier.YES;
66
67
/**
68
 * Tab pane for file editors.
69
 *
70
 * @author Karl Tauber and White Magic Software, Ltd.
71
 */
72
public final class FileEditorTabPane extends TabPane {
73
74
  private final static String FILTER_EXTENSION_TITLES =
75
      "Dialog.file.choose.filter";
76
77
  private final static Options sOptions = Services.load( Options.class );
78
  private final static Settings sSettings = Services.load( Settings.class );
79
  private final static Notifier sNotifier = Services.load( Notifier.class );
80
81
  private final ReadOnlyObjectWrapper<Path> mOpenDefinition =
82
      new ReadOnlyObjectWrapper<>();
83
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
84
      new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyBooleanWrapper mAnyFileEditorModified =
86
      new ReadOnlyBooleanWrapper();
87
  private final ChangeListener<Integer> mCaretPositionListener;
88
  private final ChangeListener<Integer> mCaretParagraphListener;
89
90
  /**
91
   * Constructs a new file editor tab pane.
92
   *
93
   * @param caretPositionListener  Listens for changes to caret position so
94
   *                               that the status bar can update.
95
   * @param caretParagraphListener Listens for changes to the caret's paragraph
96
   *                               so that scrolling may occur.
97
   */
98
  public FileEditorTabPane(
99
      final ChangeListener<Integer> caretPositionListener,
100
      final ChangeListener<Integer> caretParagraphListener ) {
101
    final ObservableList<Tab> tabs = getTabs();
102
103
    setFocusTraversable( false );
104
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
105
106
    addTabSelectionListener(
107
        ( tabPane, oldTab, newTab ) -> {
108
          if( newTab != null ) {
109
            mActiveFileEditor.set( (FileEditorTab) newTab );
110
          }
111
        }
112
    );
113
114
    final ChangeListener<Boolean> modifiedListener =
115
        ( observable, oldValue, newValue ) -> {
116
          for( final Tab tab : tabs ) {
117
            if( ((FileEditorTab) tab).isModified() ) {
118
              mAnyFileEditorModified.set( true );
119
              break;
120
            }
121
          }
122
        };
123
124
    tabs.addListener(
125
        (ListChangeListener<Tab>) change -> {
126
          while( change.next() ) {
127
            if( change.wasAdded() ) {
128
              change.getAddedSubList().forEach(
129
                  ( tab ) -> {
130
                    final var fet = (FileEditorTab) tab;
131
                    fet.modifiedProperty()
132
                       .addListener( modifiedListener );
133
                  } );
134
            }
135
            else if( change.wasRemoved() ) {
136
              change.getRemoved().forEach(
137
                  ( tab ) ->
138
                      ((FileEditorTab) tab).modifiedProperty()
139
                                           .removeListener( modifiedListener ) );
140
            }
141
          }
142
143
          // Changes in the tabs may also change anyFileEditorModified property
144
          // (e.g. closed modified file)
145
          modifiedListener.changed( null, null, null );
146
        }
147
    );
148
149
    mCaretPositionListener = caretPositionListener;
150
    mCaretParagraphListener = caretParagraphListener;
151
  }
152
153
  /**
154
   * Allows observers to be notified when the current file editor tab changes.
155
   *
156
   * @param listener The listener to notify of tab change events.
157
   */
158
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
159
    // Observe the tab so that when a new tab is opened or selected,
160
    // a notification is kicked off.
161
    getSelectionModel().selectedItemProperty().addListener( listener );
162
  }
163
164
  /**
165
   * Returns the tab that has keyboard focus.
166
   *
167
   * @return A non-null instance.
168
   */
169
  public FileEditorTab getActiveFileEditor() {
170
    return mActiveFileEditor.get();
171
  }
172
173
  /**
174
   * Returns the property corresponding to the tab that has focus.
175
   *
176
   * @return A non-null instance.
177
   */
178
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
179
    return mActiveFileEditor.getReadOnlyProperty();
180
  }
181
182
  /**
183
   * Property that can answer whether the text has been modified.
184
   *
185
   * @return A non-null instance, true meaning the content has not been saved.
186
   */
187
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
188
    return mAnyFileEditorModified.getReadOnlyProperty();
189
  }
190
191
  /**
192
   * Creates a new editor instance from the given path.
193
   *
194
   * @param path The file to open.
195
   * @return A non-null instance.
196
   */
197
  private FileEditorTab createFileEditor( final Path path ) {
198
    assert path != null;
199
200
    final FileEditorTab tab = new FileEditorTab( path );
201
202
    tab.setOnCloseRequest( e -> {
203
      if( !canCloseEditor( tab ) ) {
204
        e.consume();
205
      }
206
      else if( isActiveFileEditor( tab ) ) {
207
        // Prevent prompting the user to save when there are no file editor
208
        // tabs open.
209
        mActiveFileEditor.set( null );
210
      }
211
    } );
212
213
    tab.addCaretPositionListener( mCaretPositionListener );
214
    tab.addCaretParagraphListener( mCaretParagraphListener );
215
216
    return tab;
217
  }
218
219
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
220
    return getActiveFileEditor() == tab;
221
  }
222
223
  private Path getDefaultPath() {
224
    final String filename = getDefaultFilename();
225
    return (new File( filename )).toPath();
226
  }
227
228
  private String getDefaultFilename() {
229
    return getSettings().getSetting( "file.default", "untitled.md" );
230
  }
231
232
  /**
233
   * Called when the user selects New from the File menu.
234
   */
235
  void newEditor() {
236
    final FileEditorTab tab = createFileEditor( getDefaultPath() );
237
238
    getTabs().add( tab );
239
    getSelectionModel().select( tab );
240
  }
241
242
  void openFileDialog() {
243
    final String title = get( "Dialog.file.choose.open.title" );
244
    final FileChooser dialog = createFileChooser( title );
245
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
246
247
    if( files != null ) {
248
      openFiles( files );
249
    }
250
  }
251
252
  /**
253
   * Opens the files into new editors, unless one of those files was a
254
   * definition file. The definition file is loaded into the definition pane,
255
   * but only the first one selected (multiple definition files will result in a
256
   * warning).
257
   *
258
   * @param files The list of non-definition files that the were requested to
259
   *              open.
260
   */
261
  private void openFiles( final List<File> files ) {
262
    final List<String> extensions =
263
        createExtensionFilter( DEFINITION ).getExtensions();
264
    final FileTypePredicate predicate =
265
        new FileTypePredicate( extensions );
266
267
    // The user might have opened multiple definitions files. These will
268
    // be discarded from the text editable files.
269
    final List<File> definitions
270
        = files.stream().filter( predicate ).collect( Collectors.toList() );
271
272
    // Create a modifiable list to remove any definition files that were
273
    // opened.
274
    final List<File> editors = new ArrayList<>( files );
275
276
    if( !editors.isEmpty() ) {
277
      saveLastDirectory( editors.get( 0 ) );
278
    }
279
280
    editors.removeAll( definitions );
281
282
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
283
    if( !editors.isEmpty() ) {
284
      openEditors( editors, 0 );
285
    }
286
287
    if( !definitions.isEmpty() ) {
288
      openDefinition( definitions.get( 0 ) );
289
    }
290
  }
291
292
  private void openEditors( final List<File> files, final int activeIndex ) {
293
    final int fileTally = files.size();
294
    final List<Tab> tabs = getTabs();
295
296
    // Close single unmodified "Untitled" tab.
297
    if( tabs.size() == 1 ) {
298
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
299
300
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
301
        closeEditor( fileEditor, false );
302
      }
303
    }
304
305
    for( int i = 0; i < fileTally; i++ ) {
306
      final Path path = files.get( i ).toPath();
307
308
      FileEditorTab fileEditorTab = findEditor( path );
309
310
      // Only open new files.
311
      if( fileEditorTab == null ) {
312
        fileEditorTab = createFileEditor( path );
313
        getTabs().add( fileEditorTab );
314
      }
315
316
      // Select the first file in the list.
317
      if( i == activeIndex ) {
318
        getSelectionModel().select( fileEditorTab );
319
      }
320
    }
321
  }
322
323
  /**
324
   * Returns a property that changes when a new definition file is opened.
325
   *
326
   * @return The path to a definition file that was opened.
327
   */
328
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
329
    return getOnOpenDefinitionFile().getReadOnlyProperty();
330
  }
331
332
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
333
    return mOpenDefinition;
334
  }
335
336
  /**
337
   * Called when the user has opened a definition file (using the file open
338
   * dialog box). This will replace the current set of definitions for the
339
   * active tab.
340
   *
341
   * @param definition The file to open.
342
   */
343
  private void openDefinition( final File definition ) {
344
    // TODO: Prevent reading this file twice when a new text document is opened.
345
    // (might be a matter of checking the value first).
346
    getOnOpenDefinitionFile().set( definition.toPath() );
347
  }
348
349
  /**
350
   * Called when the contents of the editor are to be saved.
351
   *
352
   * @param tab The tab containing content to save.
353
   * @return true The contents were saved (or needn't be saved).
354
   */
355
  public boolean saveEditor( final FileEditorTab tab ) {
356
    if( tab == null || !tab.isModified() ) {
357
      return true;
358
    }
359
360
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
361
  }
362
363
  /**
364
   * Opens the Save As dialog for the user to save the content under a new
365
   * path.
366
   *
367
   * @param tab The tab with contents to save.
368
   * @return true The contents were saved, or the tab was null.
369
   */
370
  public boolean saveEditorAs( final FileEditorTab tab ) {
371
    if( tab == null ) {
372
      return true;
373
    }
374
375
    getSelectionModel().select( tab );
376
377
    final FileChooser fileChooser = createFileChooser( get(
378
        "Dialog.file.choose.save.title" ) );
379
    final File file = fileChooser.showSaveDialog( getWindow() );
380
    if( file == null ) {
381
      return false;
382
    }
383
384
    saveLastDirectory( file );
385
    tab.setPath( file.toPath() );
386
387
    return tab.save();
388
  }
389
390
  void saveAllEditors() {
391
    for( final FileEditorTab fileEditor : getAllEditors() ) {
392
      saveEditor( fileEditor );
393
    }
394
  }
395
396
  /**
397
   * Answers whether the file has had modifications. '
398
   *
399
   * @param tab THe tab to check for modifications.
400
   * @return false The file is unmodified.
401
   */
402
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
403
  boolean canCloseEditor( final FileEditorTab tab ) {
404
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
405
    canClose.set( true );
406
407
    if( tab.isModified() ) {
408
      final Notification message = getNotifyService().createNotification(
409
          Messages.get( "Alert.file.close.title" ),
410
          Messages.get( "Alert.file.close.text" ),
411
          tab.getText()
412
      );
413
414
      final Alert confirmSave = getNotifyService().createConfirmation(
415
          getWindow(), message );
416
417
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
418
419
      buttonType.ifPresent(
420
          save -> canClose.set(
421
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
422
          )
423
      );
424
    }
425
426
    return canClose.get();
427
  }
428
429
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
430
    if( tab == null ) {
431
      return true;
432
    }
433
434
    if( save ) {
435
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
436
      Event.fireEvent( tab, event );
437
438
      if( event.isConsumed() ) {
439
        return false;
440
      }
441
    }
442
443
    getTabs().remove( tab );
444
445
    if( tab.getOnClosed() != null ) {
446
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
447
    }
448
449
    return true;
450
  }
451
452
  boolean closeAllEditors() {
453
    final FileEditorTab[] allEditors = getAllEditors();
454
    final FileEditorTab activeEditor = getActiveFileEditor();
455
456
    // try to save active tab first because in case the user decides to cancel,
457
    // then it stays active
458
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
459
      return false;
460
    }
461
462
    // This should be called any time a tab changes.
463
    persistPreferences();
464
465
    // save modified tabs
466
    for( int i = 0; i < allEditors.length; i++ ) {
467
      final FileEditorTab fileEditor = allEditors[ i ];
468
469
      if( fileEditor == activeEditor ) {
470
        continue;
471
      }
472
473
      if( fileEditor.isModified() ) {
474
        // activate the modified tab to make its modified content visible to
475
        // the user
476
        getSelectionModel().select( i );
477
478
        if( !canCloseEditor( fileEditor ) ) {
479
          return false;
480
        }
481
      }
482
    }
483
484
    // Close all tabs.
485
    for( final FileEditorTab fileEditor : allEditors ) {
486
      if( !closeEditor( fileEditor, false ) ) {
487
        return false;
488
      }
489
    }
490
491
    return getTabs().isEmpty();
492
  }
493
494
  private FileEditorTab[] getAllEditors() {
495
    final ObservableList<Tab> tabs = getTabs();
496
    final int length = tabs.size();
497
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
498
499
    for( int i = 0; i < length; i++ ) {
500
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
501
    }
502
503
    return allEditors;
504
  }
505
506
  /**
507
   * Returns the file editor tab that has the given path.
508
   *
509
   * @return null No file editor tab for the given path was found.
510
   */
511
  private FileEditorTab findEditor( final Path path ) {
512
    for( final Tab tab : getTabs() ) {
513
      final FileEditorTab fileEditor = (FileEditorTab) tab;
514
515
      if( fileEditor.isPath( path ) ) {
516
        return fileEditor;
517
      }
518
    }
519
520
    return null;
521
  }
522
523
  private FileChooser createFileChooser( String title ) {
524
    final FileChooser fileChooser = new FileChooser();
525
526
    fileChooser.setTitle( title );
527
    fileChooser.getExtensionFilters().addAll(
528
        createExtensionFilters() );
529
530
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
531
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
532
533
    if( !file.isDirectory() ) {
534
      file = new File( "." );
535
    }
536
537
    fileChooser.setInitialDirectory( file );
538
    return fileChooser;
539
  }
540
541
  private List<ExtensionFilter> createExtensionFilters() {
542
    final List<ExtensionFilter> list = new ArrayList<>();
543
544
    // TODO: Return a list of all properties that match the filter prefix.
545
    // This will allow dynamic filters to be added and removed just by
546
    // updating the properties file.
547
    list.add( createExtensionFilter( ALL ) );
548
    list.add( createExtensionFilter( SOURCE ) );
549
    list.add( createExtensionFilter( DEFINITION ) );
550
    list.add( createExtensionFilter( XML ) );
551
    return list;
552
  }
553
554
  /**
555
   * Returns a filter for file name extensions recognized by the application
556
   * that can be opened by the user.
557
   *
558
   * @param filetype Used to find the globbing pattern for extensions.
559
   * @return A filename filter suitable for use by a FileDialog instance.
560
   */
561
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
562
    final String tKey = String.format( "%s.title.%s",
563
                                       FILTER_EXTENSION_TITLES,
564
                                       filetype );
565
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
566
567
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
568
  }
569
570
  private void saveLastDirectory( final File file ) {
571
    getPreferences().put( "lastDirectory", file.getParent() );
572
  }
573
574
  public void initPreferences() {
575
    int activeIndex = 0;
576
577
    final Preferences preferences = getPreferences();
578
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
579
    final String activeFileName = preferences.get( "activeFile", null );
580
581
    final List<File> files = new ArrayList<>( fileNames.length );
582
583
    for( final String fileName : fileNames ) {
584
      final File file = new File( fileName );
585
586
      if( file.exists() ) {
587
        files.add( file );
588
589
        if( fileName.equals( activeFileName ) ) {
590
          activeIndex = files.size() - 1;
591
        }
592
      }
593
    }
594
595
    if( files.isEmpty() ) {
596
      newEditor();
597
    }
598
    else {
599
      openEditors( files, activeIndex );
600
    }
601
  }
602
603
  public void persistPreferences() {
604
    final ObservableList<Tab> allEditors = getTabs();
605
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
606
607
    for( final Tab tab : allEditors ) {
608
      final FileEditorTab fileEditor = (FileEditorTab) tab;
609
      final Path filePath = fileEditor.getPath();
610
611
      if( filePath != null ) {
612
        fileNames.add( filePath.toString() );
613
      }
614
    }
615
616
    final Preferences preferences = getPreferences();
617
    Utils.putPrefsStrings( preferences,
618
                           "file",
619
                           fileNames.toArray( new String[ 0 ] ) );
620
621
    final FileEditorTab activeEditor = getActiveFileEditor();
622
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
623
624
    if( filePath == null ) {
625
      preferences.remove( "activeFile" );
626
    }
627
    else {
628
      preferences.put( "activeFile", filePath.toString() );
629
    }
630
  }
631
632
  private List<String> getExtensions( final String key ) {
633
    return getSettings().getStringSettingList( key );
635634
  }
636635
M src/main/java/com/scrivenvar/FileType.java
4747
  TOML( "toml" ),
4848
  YAML( "yaml" ),
49
  PROPERTIES( "properties" );
49
  PROPERTIES( "properties" ),
50
  UNKNOWN( "unknown" );
5051
5152
  private final String mType;
...
6465
   *
6566
   * @param type The string to compare against this enumeration of file types.
66
   *
6767
   * @return The corresponding File Type for the given string.
68
   *
6968
   * @throws IllegalArgumentException Type not found.
7069
   */
...
8483
   *
8584
   * @param type Presumably a file name extension to check against.
86
   *
8785
   * @return true The given extension corresponds to this enumerated type.
8886
   */
M src/main/java/com/scrivenvar/Main.java
3131
import com.scrivenvar.service.Options;
3232
import com.scrivenvar.service.Snitch;
33
import com.scrivenvar.service.events.Notifier;
3433
import com.scrivenvar.util.StageState;
3534
import javafx.application.Application;
...
5554
    LogManager.getLogManager().reset();
5655
  }
57
58
  private static Application sApplication;
5956
6057
  private final Options mOptions = Services.load( Options.class );
61
  private final Notifier mNotifier = Services.load( Notifier.class );
6258
  private final Snitch mSnitch = Services.load( Snitch.class );
6359
  private final Thread mSnitchThread = new Thread( getSnitch() );
...
8480
  @Override
8581
  public void start( final Stage stage ) {
86
    initApplication();
87
    initNotifyService();
8882
    initState( stage );
8983
    initStage( stage );
...
10195
        FilePreferencesFactory.class.getName()
10296
    );
103
  }
104
105
  public static void showDocument( final String uri ) {
106
    getApplication().getHostServices().showDocument( uri );
107
  }
108
109
  private void initApplication() {
110
    sApplication = this;
111
  }
112
113
  /**
114
   * Constructs the notify service and appends the main window to the list of
115
   * notification observers.
116
   */
117
  private void initNotifyService() {
118
    mNotifier.addObserver( getMainWindow() );
11997
  }
12098
...
155133
  }
156134
157
  private synchronized Snitch getSnitch() {
135
  private Snitch getSnitch() {
158136
    return mSnitch;
159137
  }
160138
161139
  private Thread getSnitchThread() {
162140
    return mSnitchThread;
163141
  }
164142
165
  private synchronized Options getOptions() {
143
  private Options getOptions() {
166144
    return mOptions;
167
  }
168
169
  private Scene getScene() {
170
    return getMainWindow().getScene();
171145
  }
172146
173147
  private MainWindow getMainWindow() {
174148
    return mMainWindow;
175149
  }
176150
177
  private static Application getApplication() {
178
    return sApplication;
151
  private Scene getScene() {
152
    return getMainWindow().getScene();
179153
  }
180154
M src/main/java/com/scrivenvar/MainWindow.java
7474
import org.controlsfx.control.StatusBar;
7575
import org.fxmisc.richtext.StyleClassedTextArea;
76
import org.fxmisc.richtext.model.TwoDimensional;
77
78
import java.io.File;
79
import java.nio.file.Path;
80
import java.util.HashMap;
81
import java.util.Map;
82
import java.util.Observable;
83
import java.util.Observer;
84
import java.util.concurrent.atomic.AtomicInteger;
85
import java.util.function.Consumer;
86
import java.util.function.Function;
87
import java.util.prefs.Preferences;
88
89
import static com.scrivenvar.Constants.*;
90
import static com.scrivenvar.Messages.get;
91
import static com.scrivenvar.util.StageState.*;
92
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
93
import static javafx.event.Event.fireEvent;
94
import static javafx.scene.input.KeyCode.ENTER;
95
import static javafx.scene.input.KeyCode.TAB;
96
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
97
98
/**
99
 * Main window containing a tab pane in the center for file editors.
100
 *
101
 * @author Karl Tauber and White Magic Software, Ltd.
102
 */
103
public class MainWindow implements Observer {
104
105
  /**
106
   * The {@code OPTIONS} variable must be declared before all other variables
107
   * to prevent subsequent initializations from failing due to missing user
108
   * preferences.
109
   */
110
  private final static Options OPTIONS = Services.load( Options.class );
111
  private final static Snitch SNITCH = Services.load( Snitch.class );
112
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
113
114
  private final Scene mScene;
115
  private final StatusBar mStatusBar;
116
  private final Text mLineNumberText;
117
  private final TextField mFindTextField;
118
119
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
120
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
121
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
122
  private FileEditorTabPane mFileEditorPane;
123
124
  /**
125
   * Prevents re-instantiation of processing classes.
126
   */
127
  private final Map<FileEditorTab, Processor<String>> mProcessors =
128
      new HashMap<>();
129
130
  private final Map<String, String> mResolvedMap =
131
      new HashMap<>( DEFAULT_MAP_SIZE );
132
133
  /**
134
   * Listens on the definition pane for double-click events.
135
   */
136
  private VariableNameInjector variableNameInjector;
137
138
  /**
139
   * Called when the definition data is changed.
140
   */
141
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
142
      mTreeHandler = event -> {
143
    exportDefinitions( getDefinitionPath() );
144
    interpolateResolvedMap();
145
    refreshActiveTab();
146
  };
147
148
  /**
149
   * Called to inject the selected item when the user presses ENTER in the
150
   * definition pane.
151
   */
152
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
153
      event -> {
154
        if( event.getCode() == ENTER ) {
155
          getVariableNameInjector().injectSelectedItem();
156
        }
157
      };
158
159
  /**
160
   * Called to switch to the definition pane when the user presses TAB.
161
   */
162
  private final EventHandler<? super KeyEvent> mEditorKeyHandler =
163
      (EventHandler<KeyEvent>) event -> {
164
        if( event.getCode() == TAB ) {
165
          getDefinitionPane().requestFocus();
166
          event.consume();
167
        }
168
      };
169
170
  private final Object mMutex = new Object();
171
  private final AtomicInteger mScrollRatio = new AtomicInteger( 0 );
172
173
  /**
174
   * Called to synchronize the scrolling areas.
175
   */
176
  private final Consumer<Double> mScrollEventObserver = o -> {
177
    final boolean scrolling = false;
178
    final var pPreviewPane = getPreviewPane();
179
    final var pScrollPane = pPreviewPane.getScrollPane();
180
181
    // If the user is deliberately using the scrollbar then synchronize
182
    // them by calculating the ratios.
183
    if( scrolling ) {
184
      final var eScrollPane = getActiveEditor().getScrollPane();
185
      final int eScrollY =
186
          eScrollPane.estimatedScrollYProperty().getValue().intValue();
187
      final int eHeight = (int)
188
          (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
189
              - eScrollPane.getHeight());
190
      final double eRatio = eHeight > 0
191
          ? Math.min( Math.max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
192
193
      final var pScrollBar = pPreviewPane.getVerticalScrollBar();
194
      final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
195
      final var pScrollY = (int) (pHeight * eRatio);
196
197
      // Reduce concurrent modification exceptions when setting the vertical
198
      // scroll bar position.
199
      synchronized( mMutex ) {
200
        Platform.runLater( () -> {
201
          pScrollBar.setValue( pScrollY );
202
          pScrollPane.repaint();
203
        } );
204
      }
205
    }
206
    else {
207
      synchronized( mMutex ) {
208
        Platform.runLater( () -> {
209
          final String id = getActiveEditor().getCurrentParagraphId();
210
          pPreviewPane.scrollTo( id );
211
          pScrollPane.repaint();
212
        } );
213
      }
214
    }
215
  };
216
217
  private final ChangeListener<Integer> mCaretListener = ( i, j, k ) -> {
218
    final FileEditorTab tab = getActiveFileEditor();
219
    final EditorPane pane = tab.getEditorPane();
220
    final StyleClassedTextArea editor = pane.getEditor();
221
222
    getLineNumberText().setText(
223
        get( STATUS_BAR_LINE,
224
             editor.getCurrentParagraph() + 1,
225
             editor.getParagraphs().size(),
226
             editor.getCaretPosition()
227
        )
228
    );
229
  };
230
231
  public MainWindow() {
232
    mStatusBar = createStatusBar();
233
    mLineNumberText = createLineNumberText();
234
    mFindTextField = createFindTextField();
235
    mScene = createScene();
236
237
    initLayout();
238
    initFindInput();
239
    initSnitch();
240
    initDefinitionListener();
241
    initTabAddedListener();
242
    initTabChangedListener();
243
    restorePreferences();
244
  }
245
246
  private void initLayout() {
247
    final Scene appScene = getScene();
248
249
    appScene.getStylesheets().add( STYLESHEET_SCENE );
250
251
    // TODO: Apply an XML syntax highlighting for XML files.
252
//    appScene.getStylesheets().add( STYLESHEET_XML );
253
    appScene.windowProperty().addListener(
254
        ( observable, oldWindow, newWindow ) ->
255
            newWindow.setOnCloseRequest(
256
                e -> {
257
                  if( !getFileEditorPane().closeAllEditors() ) {
258
                    e.consume();
259
                  }
260
                }
261
            )
262
    );
263
  }
264
265
  /**
266
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
267
   * presses.
268
   */
269
  private void initFindInput() {
270
    final TextField input = getFindTextField();
271
272
    input.setOnKeyPressed( ( KeyEvent event ) -> {
273
      switch( event.getCode() ) {
274
        case F3:
275
        case ENTER:
276
          editFindNext();
277
          break;
278
        case F:
279
          if( !event.isControlDown() ) {
280
            break;
281
          }
282
        case ESCAPE:
283
          getStatusBar().setGraphic( null );
284
          getActiveFileEditor().getEditorPane().requestFocus();
285
          break;
286
      }
287
    } );
288
289
    // Remove when the input field loses focus.
290
    input.focusedProperty().addListener(
291
        (
292
            final ObservableValue<? extends Boolean> focused,
293
            final Boolean oFocus,
294
            final Boolean nFocus ) -> {
295
          if( !nFocus ) {
296
            getStatusBar().setGraphic( null );
297
          }
298
        }
299
    );
300
  }
301
302
  /**
303
   * Watch for changes to external files. In particular, this awaits
304
   * modifications to any XSL files associated with XML files being edited. When
305
   * an XSL file is modified (external to the application), the snitch's ears
306
   * perk up and the file is reloaded. This keeps the XSL transformation up to
307
   * date with what's on the file system.
308
   */
309
  private void initSnitch() {
310
    SNITCH.addObserver( this );
311
  }
312
313
  /**
314
   * Listen for {@link FileEditorTabPane} to receive open definition file event.
315
   */
316
  private void initDefinitionListener() {
317
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
318
        ( final ObservableValue<? extends Path> file,
319
          final Path oldPath, final Path newPath ) -> {
320
          // Indirectly refresh the resolved map.
321
          resetProcessors();
322
323
          openDefinitions( newPath );
324
325
          // Will create new processors and therefore a new resolved map.
326
          refreshActiveTab();
327
        }
328
    );
329
  }
330
331
  /**
332
   * When tabs are added, hook the various change listeners onto the new tab so
333
   * that the preview pane refreshes as necessary.
334
   */
335
  private void initTabAddedListener() {
336
    final FileEditorTabPane editorPane = getFileEditorPane();
337
338
    // Make sure the text processor kicks off when new files are opened.
339
    final ObservableList<Tab> tabs = editorPane.getTabs();
340
341
    // Update the preview pane on tab changes.
342
    tabs.addListener(
343
        ( final Change<? extends Tab> change ) -> {
344
          while( change.next() ) {
345
            if( change.wasAdded() ) {
346
              // Multiple tabs can be added simultaneously.
347
              for( final Tab newTab : change.getAddedSubList() ) {
348
                final FileEditorTab tab = (FileEditorTab) newTab;
349
350
                initTextChangeListener( tab );
351
                initKeyboardEventListeners( tab );
352
//              initSyntaxListener( tab );
353
              }
354
            }
355
          }
356
        }
357
    );
358
  }
359
360
  /**
361
   * Listen for new tab selection events.
362
   */
363
  private void initTabChangedListener() {
364
    final FileEditorTabPane editorPane = getFileEditorPane();
365
366
    // Update the preview pane changing tabs.
367
    editorPane.addTabSelectionListener(
368
        ( ObservableValue<? extends Tab> tabPane,
369
          final Tab oldTab, final Tab newTab ) -> {
370
          updateVariableNameInjector();
371
372
          // If there was no old tab, then this is a first time load, which
373
          // can be ignored.
374
          if( oldTab != null ) {
375
            if( newTab == null ) {
376
              closeRemainingTab();
377
            }
378
            else {
379
              // Update the preview with the edited text.
380
              refreshSelectedTab( (FileEditorTab) newTab );
381
            }
382
          }
383
        }
384
    );
385
  }
386
387
  /**
388
   * Reloads the preferences from the previous session.
389
   */
390
  private void restorePreferences() {
391
    restoreDefinitionPane();
392
    getFileEditorPane().restorePreferences();
393
  }
394
395
  /**
396
   * Ensure that the keyboard events are received when a new tab is added
397
   * to the user interface.
398
   *
399
   * @param tab The tab that can trigger keyboard events, such as control+space.
400
   */
401
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
402
    final VariableNameInjector vin = getVariableNameInjector();
403
    vin.initKeyboardEventListeners( tab );
404
405
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler );
406
  }
407
408
  private void initTextChangeListener( final FileEditorTab tab ) {
409
    tab.addTextChangeListener(
410
        ( ObservableValue<? extends String> editor,
411
          final String oldValue, final String newValue ) ->
412
            refreshSelectedTab( tab )
413
    );
414
  }
415
416
  private void updateVariableNameInjector() {
417
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
418
  }
419
420
  private void setVariableNameInjector( final VariableNameInjector injector ) {
421
    this.variableNameInjector = injector;
422
  }
423
424
  private synchronized VariableNameInjector getVariableNameInjector() {
425
    if( this.variableNameInjector == null ) {
426
      final VariableNameInjector vin = createVariableNameInjector();
427
      setVariableNameInjector( vin );
428
    }
429
430
    return this.variableNameInjector;
431
  }
432
433
  private VariableNameInjector createVariableNameInjector() {
434
    final FileEditorTab tab = getActiveFileEditor();
435
    final DefinitionPane pane = getDefinitionPane();
436
437
    return new VariableNameInjector( tab, pane );
438
  }
439
440
  /**
441
   * Called whenever the preview pane becomes out of sync with the file editor
442
   * tab. This can be called when the text changes, the caret paragraph changes,
443
   * or the file tab changes.
444
   *
445
   * @param tab The file editor tab that has been changed in some fashion.
446
   */
447
  private void refreshSelectedTab( final FileEditorTab tab ) {
448
    if( tab == null ) {
449
      return;
450
    }
451
452
    getPreviewPane().setPath( tab.getPath() );
453
454
    Processor<String> processor = getProcessors().get( tab );
455
456
    if( processor == null ) {
457
      processor = createProcessor( tab );
458
      getProcessors().put( tab, processor );
459
    }
460
461
    try {
462
      processor.processChain( tab.getEditorText() );
463
    } catch( final Exception ex ) {
464
      error( ex );
465
    }
466
  }
467
468
  private void refreshActiveTab() {
469
    refreshSelectedTab( getActiveFileEditor() );
470
  }
471
472
  /**
473
   * Called when a definition source is opened.
474
   *
475
   * @param path Path to the definition source that was opened.
476
   */
477
  private void openDefinitions( final Path path ) {
478
    try {
479
      final DefinitionSource ds = createDefinitionSource( path );
480
      setDefinitionSource( ds );
481
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
482
      getUserPreferences().save();
483
484
      final Tooltip tooltipPath = new Tooltip( path.toString() );
485
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
486
487
      final DefinitionPane pane = getDefinitionPane();
488
      pane.update( ds );
489
      pane.addTreeChangeHandler( mTreeHandler );
490
      pane.addKeyEventHandler( mDefinitionKeyHandler );
491
      pane.filenameProperty().setValue( path.getFileName().toString() );
492
      pane.setTooltip( tooltipPath );
493
494
      interpolateResolvedMap();
495
    } catch( final Exception e ) {
496
      error( e );
497
    }
498
  }
499
500
  private void exportDefinitions( final Path path ) {
501
    try {
502
      final DefinitionPane pane = getDefinitionPane();
503
      final TreeItem<String> root = pane.getTreeView().getRoot();
504
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
505
506
      if( problemChild == null ) {
507
        getDefinitionSource().getTreeAdapter().export( root, path );
508
        getNotifier().clear();
509
      }
510
      else {
511
        final String msg = get( "yaml.error.tree.form",
512
                                problemChild.getValue() );
513
        getNotifier().notify( msg );
514
      }
515
    } catch( final Exception e ) {
516
      error( e );
517
    }
518
  }
519
520
  private void interpolateResolvedMap() {
521
    final Map<String, String> treeMap = getDefinitionPane().toMap();
522
    final Map<String, String> map = new HashMap<>( treeMap );
523
    MapInterpolator.interpolate( map );
524
525
    getResolvedMap().clear();
526
    getResolvedMap().putAll( map );
527
  }
528
529
  private void restoreDefinitionPane() {
530
    openDefinitions( getDefinitionPath() );
531
  }
532
533
  /**
534
   * Called when the last open tab is closed to clear the preview pane.
535
   */
536
  private void closeRemainingTab() {
537
    getPreviewPane().clear();
538
  }
539
540
  /**
541
   * Called when an exception occurs that warrants the user's attention.
542
   *
543
   * @param e The exception with a message that the user should know about.
544
   */
545
  private void error( final Exception e ) {
546
    getNotifier().notify( e );
547
  }
548
549
  //---- File actions -------------------------------------------------------
550
551
  /**
552
   * Called when an observable instance has changed. This is called by both the
553
   * snitch service and the notify service. The snitch service can be called for
554
   * different file types, including definition sources.
555
   *
556
   * @param observable The observed instance.
557
   * @param value      The noteworthy item.
558
   */
559
  @Override
560
  public void update( final Observable observable, final Object value ) {
561
    if( value != null ) {
562
      if( observable instanceof Snitch && value instanceof Path ) {
563
        updateSelectedTab();
564
      }
565
      else if( observable instanceof Notifier && value instanceof String ) {
566
        updateStatusBar( (String) value );
567
      }
568
    }
569
  }
570
571
  /**
572
   * Updates the status bar to show the given message.
573
   *
574
   * @param s The message to show in the status bar.
575
   */
576
  private void updateStatusBar( final String s ) {
577
    Platform.runLater(
578
        () -> {
579
          final int index = s.indexOf( '\n' );
580
          final String message = s.substring(
581
              0, index > 0 ? index : s.length() );
582
583
          getStatusBar().setText( message );
584
        }
585
    );
586
  }
587
588
  /**
589
   * Called when a file has been modified.
590
   */
591
  private void updateSelectedTab() {
592
    Platform.runLater(
593
        () -> {
594
          // Brute-force XSLT file reload by re-instantiating all processors.
595
          resetProcessors();
596
          refreshActiveTab();
597
        }
598
    );
599
  }
600
601
  /**
602
   * After resetting the processors, they will refresh anew to be up-to-date
603
   * with the files (text and definition) currently loaded into the editor.
604
   */
605
  private void resetProcessors() {
606
    getProcessors().clear();
607
  }
608
609
  //---- File actions -------------------------------------------------------
610
611
  private void fileNew() {
612
    getFileEditorPane().newEditor();
613
  }
614
615
  private void fileOpen() {
616
    getFileEditorPane().openFileDialog();
617
  }
618
619
  private void fileClose() {
620
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
621
  }
622
623
  /**
624
   * TODO: Upon closing, first remove the tab change listeners. (There's no
625
   * need to re-render each tab when all are being closed.)
626
   */
627
  private void fileCloseAll() {
628
    getFileEditorPane().closeAllEditors();
629
  }
630
631
  private void fileSave() {
632
    getFileEditorPane().saveEditor( getActiveFileEditor() );
633
  }
634
635
  private void fileSaveAs() {
636
    final FileEditorTab editor = getActiveFileEditor();
637
    getFileEditorPane().saveEditorAs( editor );
638
    getProcessors().remove( editor );
639
640
    try {
641
      refreshSelectedTab( editor );
642
    } catch( final Exception ex ) {
643
      getNotifier().notify( ex );
644
    }
645
  }
646
647
  private void fileSaveAll() {
648
    getFileEditorPane().saveAllEditors();
649
  }
650
651
  private void fileExit() {
652
    final Window window = getWindow();
653
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
654
  }
655
656
  //---- Edit actions -------------------------------------------------------
657
658
  /**
659
   * Used to find text in the active file editor window.
660
   */
661
  private void editFind() {
662
    final TextField input = getFindTextField();
663
    getStatusBar().setGraphic( input );
664
    input.requestFocus();
665
  }
666
667
  public void editFindNext() {
668
    getActiveFileEditor().searchNext( getFindTextField().getText() );
669
  }
670
671
  public void editPreferences() {
672
    getUserPreferences().show();
673
  }
674
675
  //---- Insert actions -----------------------------------------------------
676
677
  /**
678
   * Delegates to the active editor to handle wrapping the current text
679
   * selection with leading and trailing strings.
680
   *
681
   * @param leading  The string to put before the selection.
682
   * @param trailing The string to put after the selection.
683
   */
684
  private void insertMarkdown(
685
      final String leading, final String trailing ) {
686
    getActiveEditor().surroundSelection( leading, trailing );
687
  }
688
689
  @SuppressWarnings("SameParameterValue")
690
  private void insertMarkdown(
691
      final String leading, final String trailing, final String hint ) {
692
    getActiveEditor().surroundSelection( leading, trailing, hint );
693
  }
694
695
  //---- Help actions -------------------------------------------------------
696
697
  private void helpAbout() {
698
    final Alert alert = new Alert( AlertType.INFORMATION );
699
    alert.setTitle( get( "Dialog.about.title" ) );
700
    alert.setHeaderText( get( "Dialog.about.header" ) );
701
    alert.setContentText( get( "Dialog.about.content" ) );
702
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
703
    alert.initOwner( getWindow() );
704
705
    alert.showAndWait();
706
  }
707
708
  //---- Member creators ----------------------------------------------------
709
710
  /**
711
   * Factory to create processors that are suited to different file types.
712
   *
713
   * @param tab The tab that is subjected to processing.
714
   * @return A processor suited to the file type specified by the tab's path.
715
   */
716
  private Processor<String> createProcessor( final FileEditorTab tab ) {
717
    return createProcessorFactory().createProcessor( tab );
718
  }
719
720
  private ProcessorFactory createProcessorFactory() {
721
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
722
  }
723
724
  private HTMLPreviewPane createHTMLPreviewPane() {
725
    return new HTMLPreviewPane();
726
  }
727
728
  private DefinitionSource createDefaultDefinitionSource() {
729
    return new YamlDefinitionSource( getDefinitionPath() );
730
  }
731
732
  private DefinitionSource createDefinitionSource( final Path path ) {
733
    try {
734
      return createDefinitionFactory().createDefinitionSource( path );
735
    } catch( final Exception ex ) {
736
      error( ex );
737
      return createDefaultDefinitionSource();
738
    }
739
  }
740
741
  private TextField createFindTextField() {
742
    return new TextField();
743
  }
744
745
  /**
746
   * Create an editor pane to hold file editor tabs.
747
   *
748
   * @return A new instance, never null.
749
   */
750
  private FileEditorTabPane createFileEditorPane() {
751
    return new FileEditorTabPane( mScrollEventObserver, mCaretListener );
752
  }
753
754
  private DefinitionFactory createDefinitionFactory() {
755
    return new DefinitionFactory();
756
  }
757
758
  private StatusBar createStatusBar() {
759
    return new StatusBar();
760
  }
761
762
  private Scene createScene() {
763
    final SplitPane splitPane = new SplitPane(
764
        getDefinitionPane().getNode(),
765
        getFileEditorPane().getNode(),
766
        getPreviewPane().getNode() );
767
768
    splitPane.setDividerPositions(
769
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
770
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
771
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
772
773
    getDefinitionPane().prefHeightProperty().bind( splitPane.heightProperty() );
774
775
    final BorderPane borderPane = new BorderPane();
776
    borderPane.setPrefSize( 1024, 800 );
777
    borderPane.setTop( createMenuBar() );
778
    borderPane.setBottom( getStatusBar() );
779
    borderPane.setCenter( splitPane );
780
781
    final VBox statusBar = new VBox();
782
    statusBar.setAlignment( Pos.BASELINE_CENTER );
783
    statusBar.getChildren().add( getLineNumberText() );
784
    getStatusBar().getRightItems().add( statusBar );
785
786
    return new Scene( borderPane );
787
  }
788
789
  private Text createLineNumberText() {
790
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
791
  }
792
793
  private Node createMenuBar() {
794
    final BooleanBinding activeFileEditorIsNull =
795
        getFileEditorPane().activeFileEditorProperty().isNull();
796
797
    // File actions
798
    final Action fileNewAction = new ActionBuilder()
799
        .setText( "Main.menu.file.new" )
800
        .setAccelerator( "Shortcut+N" )
801
        .setIcon( FILE_ALT )
802
        .setAction( e -> fileNew() )
803
        .build();
804
    final Action fileOpenAction = new ActionBuilder()
805
        .setText( "Main.menu.file.open" )
806
        .setAccelerator( "Shortcut+O" )
807
        .setIcon( FOLDER_OPEN_ALT )
808
        .setAction( e -> fileOpen() )
809
        .build();
810
    final Action fileCloseAction = new ActionBuilder()
811
        .setText( "Main.menu.file.close" )
812
        .setAccelerator( "Shortcut+W" )
813
        .setAction( e -> fileClose() )
814
        .setDisable( activeFileEditorIsNull )
815
        .build();
816
    final Action fileCloseAllAction = new ActionBuilder()
817
        .setText( "Main.menu.file.close_all" )
818
        .setAction( e -> fileCloseAll() )
819
        .setDisable( activeFileEditorIsNull )
820
        .build();
821
    final Action fileSaveAction = new ActionBuilder()
822
        .setText( "Main.menu.file.save" )
823
        .setAccelerator( "Shortcut+S" )
824
        .setIcon( FLOPPY_ALT )
825
        .setAction( e -> fileSave() )
826
        .setDisable( createActiveBooleanProperty(
827
            FileEditorTab::modifiedProperty ).not() )
828
        .build();
829
    final Action fileSaveAsAction = new ActionBuilder()
830
        .setText( "Main.menu.file.save_as" )
831
        .setAction( e -> fileSaveAs() )
832
        .setDisable( activeFileEditorIsNull )
833
        .build();
834
    final Action fileSaveAllAction = new ActionBuilder()
835
        .setText( "Main.menu.file.save_all" )
836
        .setAccelerator( "Shortcut+Shift+S" )
837
        .setAction( e -> fileSaveAll() )
838
        .setDisable( Bindings.not(
839
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
840
        .build();
841
    final Action fileExitAction = new ActionBuilder()
842
        .setText( "Main.menu.file.exit" )
843
        .setAction( e -> fileExit() )
844
        .build();
845
846
    // Edit actions
847
    final Action editUndoAction = new ActionBuilder()
848
        .setText( "Main.menu.edit.undo" )
849
        .setAccelerator( "Shortcut+Z" )
850
        .setIcon( UNDO )
851
        .setAction( e -> getActiveEditor().undo() )
852
        .setDisable( createActiveBooleanProperty(
853
            FileEditorTab::canUndoProperty ).not() )
854
        .build();
855
    final Action editRedoAction = new ActionBuilder()
856
        .setText( "Main.menu.edit.redo" )
857
        .setAccelerator( "Shortcut+Y" )
858
        .setIcon( REPEAT )
859
        .setAction( e -> getActiveEditor().redo() )
860
        .setDisable( createActiveBooleanProperty(
861
            FileEditorTab::canRedoProperty ).not() )
862
        .build();
863
    final Action editFindAction = new ActionBuilder()
864
        .setText( "Main.menu.edit.find" )
865
        .setAccelerator( "Ctrl+F" )
866
        .setIcon( SEARCH )
867
        .setAction( e -> editFind() )
868
        .setDisable( activeFileEditorIsNull )
869
        .build();
870
    final Action editFindNextAction = new ActionBuilder()
871
        .setText( "Main.menu.edit.find.next" )
872
        .setAccelerator( "F3" )
873
        .setIcon( null )
874
        .setAction( e -> editFindNext() )
875
        .setDisable( activeFileEditorIsNull )
876
        .build();
877
    final Action editPreferencesAction = new ActionBuilder()
878
        .setText( "Main.menu.edit.preferences" )
879
        .setAccelerator( "Ctrl+Alt+S" )
880
        .setAction( e -> editPreferences() )
881
        .build();
882
883
    // Insert actions
884
    final Action insertBoldAction = new ActionBuilder()
885
        .setText( "Main.menu.insert.bold" )
886
        .setAccelerator( "Shortcut+B" )
887
        .setIcon( BOLD )
888
        .setAction( e -> insertMarkdown( "**", "**" ) )
889
        .setDisable( activeFileEditorIsNull )
890
        .build();
891
    final Action insertItalicAction = new ActionBuilder()
892
        .setText( "Main.menu.insert.italic" )
893
        .setAccelerator( "Shortcut+I" )
894
        .setIcon( ITALIC )
895
        .setAction( e -> insertMarkdown( "*", "*" ) )
896
        .setDisable( activeFileEditorIsNull )
897
        .build();
898
    final Action insertSuperscriptAction = new ActionBuilder()
899
        .setText( "Main.menu.insert.superscript" )
900
        .setAccelerator( "Shortcut+[" )
901
        .setIcon( SUPERSCRIPT )
902
        .setAction( e -> insertMarkdown( "^", "^" ) )
903
        .setDisable( activeFileEditorIsNull )
904
        .build();
905
    final Action insertSubscriptAction = new ActionBuilder()
906
        .setText( "Main.menu.insert.subscript" )
907
        .setAccelerator( "Shortcut+]" )
908
        .setIcon( SUBSCRIPT )
909
        .setAction( e -> insertMarkdown( "~", "~" ) )
910
        .setDisable( activeFileEditorIsNull )
911
        .build();
912
    final Action insertStrikethroughAction = new ActionBuilder()
913
        .setText( "Main.menu.insert.strikethrough" )
914
        .setAccelerator( "Shortcut+T" )
915
        .setIcon( STRIKETHROUGH )
916
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
917
        .setDisable( activeFileEditorIsNull )
918
        .build();
919
    final Action insertBlockquoteAction = new ActionBuilder()
920
        .setText( "Main.menu.insert.blockquote" )
921
        .setAccelerator( "Ctrl+Q" )
922
        .setIcon( QUOTE_LEFT )
923
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
924
        .setDisable( activeFileEditorIsNull )
925
        .build();
926
    final Action insertCodeAction = new ActionBuilder()
927
        .setText( "Main.menu.insert.code" )
928
        .setAccelerator( "Shortcut+K" )
929
        .setIcon( CODE )
930
        .setAction( e -> insertMarkdown( "`", "`" ) )
931
        .setDisable( activeFileEditorIsNull )
932
        .build();
933
    final Action insertFencedCodeBlockAction = new ActionBuilder()
934
        .setText( "Main.menu.insert.fenced_code_block" )
935
        .setAccelerator( "Shortcut+Shift+K" )
936
        .setIcon( FILE_CODE_ALT )
937
        .setAction( e -> getActiveEditor().surroundSelection(
938
            "\n\n```\n",
939
            "\n```\n\n",
940
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
941
        .setDisable( activeFileEditorIsNull )
942
        .build();
943
    final Action insertLinkAction = new ActionBuilder()
944
        .setText( "Main.menu.insert.link" )
945
        .setAccelerator( "Shortcut+L" )
946
        .setIcon( LINK )
947
        .setAction( e -> getActiveEditor().insertLink() )
948
        .setDisable( activeFileEditorIsNull )
949
        .build();
950
    final Action insertImageAction = new ActionBuilder()
951
        .setText( "Main.menu.insert.image" )
952
        .setAccelerator( "Shortcut+G" )
953
        .setIcon( PICTURE_ALT )
954
        .setAction( e -> getActiveEditor().insertImage() )
955
        .setDisable( activeFileEditorIsNull )
956
        .build();
957
958
    // Number of header actions (H1 ... H3)
959
    final int HEADERS = 3;
960
    final Action[] headers = new Action[ HEADERS ];
961
962
    for( int i = 1; i <= HEADERS; i++ ) {
963
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
964
      final String markup = String.format( "%n%n%s ", hashes );
965
      final String text = "Main.menu.insert.header." + i;
966
      final String accelerator = "Shortcut+" + i;
967
      final String prompt = text + ".prompt";
968
969
      headers[ i - 1 ] = new ActionBuilder()
970
          .setText( text )
971
          .setAccelerator( accelerator )
972
          .setIcon( HEADER )
973
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
974
          .setDisable( activeFileEditorIsNull )
975
          .build();
976
    }
977
978
    final Action insertUnorderedListAction = new ActionBuilder()
979
        .setText( "Main.menu.insert.unordered_list" )
980
        .setAccelerator( "Shortcut+U" )
981
        .setIcon( LIST_UL )
982
        .setAction( e -> getActiveEditor()
983
            .surroundSelection( "\n\n* ", "" ) )
984
        .setDisable( activeFileEditorIsNull )
985
        .build();
986
    final Action insertOrderedListAction = new ActionBuilder()
987
        .setText( "Main.menu.insert.ordered_list" )
988
        .setAccelerator( "Shortcut+Shift+O" )
989
        .setIcon( LIST_OL )
990
        .setAction( e -> insertMarkdown(
991
            "\n\n1. ", "" ) )
992
        .setDisable( activeFileEditorIsNull )
993
        .build();
994
    final Action insertHorizontalRuleAction = new ActionBuilder()
995
        .setText( "Main.menu.insert.horizontal_rule" )
996
        .setAccelerator( "Shortcut+H" )
997
        .setAction( e -> insertMarkdown(
998
            "\n\n---\n\n", "" ) )
999
        .setDisable( activeFileEditorIsNull )
1000
        .build();
1001
1002
    // Help actions
1003
    final Action helpAboutAction = new ActionBuilder()
1004
        .setText( "Main.menu.help.about" )
1005
        .setAction( e -> helpAbout() )
1006
        .build();
1007
1008
    //---- MenuBar ----
1009
    final Menu fileMenu = ActionUtils.createMenu(
1010
        get( "Main.menu.file" ),
1011
        fileNewAction,
1012
        fileOpenAction,
1013
        null,
1014
        fileCloseAction,
1015
        fileCloseAllAction,
1016
        null,
1017
        fileSaveAction,
1018
        fileSaveAsAction,
1019
        fileSaveAllAction,
1020
        null,
1021
        fileExitAction );
1022
1023
    final Menu editMenu = ActionUtils.createMenu(
1024
        get( "Main.menu.edit" ),
1025
        editUndoAction,
1026
        editRedoAction,
1027
        editFindAction,
1028
        editFindNextAction,
1029
        null,
1030
        editPreferencesAction );
1031
1032
    final Menu insertMenu = ActionUtils.createMenu(
1033
        get( "Main.menu.insert" ),
1034
        insertBoldAction,
1035
        insertItalicAction,
1036
        insertSuperscriptAction,
1037
        insertSubscriptAction,
1038
        insertStrikethroughAction,
1039
        insertBlockquoteAction,
1040
        insertCodeAction,
1041
        insertFencedCodeBlockAction,
1042
        null,
1043
        insertLinkAction,
1044
        insertImageAction,
1045
        null,
1046
        headers[ 0 ],
1047
        headers[ 1 ],
1048
        headers[ 2 ],
1049
        null,
1050
        insertUnorderedListAction,
1051
        insertOrderedListAction,
1052
        insertHorizontalRuleAction );
1053
1054
    final Menu helpMenu = ActionUtils.createMenu(
1055
        get( "Main.menu.help" ),
1056
        helpAboutAction );
1057
1058
    final MenuBar menuBar = new MenuBar(
1059
        fileMenu,
1060
        editMenu,
1061
        insertMenu,
1062
        helpMenu );
1063
1064
    //---- ToolBar ----
1065
    final ToolBar toolBar = ActionUtils.createToolBar(
1066
        fileNewAction,
1067
        fileOpenAction,
1068
        fileSaveAction,
1069
        null,
1070
        editUndoAction,
1071
        editRedoAction,
1072
        null,
1073
        insertBoldAction,
1074
        insertItalicAction,
1075
        insertSuperscriptAction,
1076
        insertSubscriptAction,
1077
        insertBlockquoteAction,
1078
        insertCodeAction,
1079
        insertFencedCodeBlockAction,
1080
        null,
1081
        insertLinkAction,
1082
        insertImageAction,
1083
        null,
1084
        headers[ 0 ],
1085
        null,
1086
        insertUnorderedListAction,
1087
        insertOrderedListAction );
1088
1089
    return new VBox( menuBar, toolBar );
1090
  }
1091
1092
  /**
1093
   * Creates a boolean property that is bound to another boolean value of the
1094
   * active editor.
1095
   */
1096
  private BooleanProperty createActiveBooleanProperty(
1097
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1098
1099
    final BooleanProperty b = new SimpleBooleanProperty();
1100
    final FileEditorTab tab = getActiveFileEditor();
1101
1102
    if( tab != null ) {
1103
      b.bind( func.apply( tab ) );
1104
    }
1105
1106
    getFileEditorPane().activeFileEditorProperty().addListener(
1107
        ( observable, oldFileEditor, newFileEditor ) -> {
1108
          b.unbind();
1109
1110
          if( newFileEditor == null ) {
1111
            b.set( false );
1112
          }
1113
          else {
1114
            b.bind( func.apply( newFileEditor ) );
1115
          }
1116
        }
1117
    );
1118
1119
    return b;
1120
  }
1121
1122
  //---- Convenience accessors ----------------------------------------------
1123
1124
  private Preferences getPreferences() {
1125
    return OPTIONS.getState();
1126
  }
1127
1128
  private float getFloat( final String key, final float defaultValue ) {
1129
    return getPreferences().getFloat( key, defaultValue );
1130
  }
1131
1132
  public Window getWindow() {
1133
    return getScene().getWindow();
1134
  }
1135
1136
  private MarkdownEditorPane getActiveEditor() {
1137
    final EditorPane pane = getActiveFileEditor().getEditorPane();
1138
1139
    return pane instanceof MarkdownEditorPane
1140
        ? (MarkdownEditorPane) pane
1141
        : new MarkdownEditorPane();
1142
  }
1143
1144
  private FileEditorTab getActiveFileEditor() {
1145
    return getFileEditorPane().getActiveFileEditor();
1146
  }
1147
1148
  //---- Member accessors ---------------------------------------------------
1149
1150
  protected Scene getScene() {
1151
    return mScene;
1152
  }
1153
1154
  private Map<FileEditorTab, Processor<String>> getProcessors() {
1155
    return mProcessors;
1156
  }
1157
1158
  private FileEditorTabPane getFileEditorPane() {
1159
    var pane = mFileEditorPane;
1160
1161
    if( pane == null ) {
1162
      pane = createFileEditorPane();
1163
    }
1164
1165
    return mFileEditorPane = pane;
1166
  }
1167
1168
  private HTMLPreviewPane getPreviewPane() {
1169
    return mPreviewPane;
1170
  }
1171
1172
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
1173
    assert definitionSource != null;
1174
    mDefinitionSource = definitionSource;
1175
  }
1176
1177
  private DefinitionSource getDefinitionSource() {
1178
    return mDefinitionSource;
1179
  }
1180
1181
  private DefinitionPane getDefinitionPane() {
1182
    return mDefinitionPane;
1183
  }
1184
1185
  private Text getLineNumberText() {
1186
    return mLineNumberText;
1187
  }
1188
1189
  private StatusBar getStatusBar() {
1190
    return mStatusBar;
1191
  }
1192
1193
  private TextField getFindTextField() {
1194
    return mFindTextField;
1195
  }
1196
1197
  /**
1198
   * Returns the variable map of interpolated definitions.
1199
   *
1200
   * @return A map to help dereference variables.
1201
   */
1202
  private Map<String, String> getResolvedMap() {
1203
    return mResolvedMap;
1204
  }
1205
1206
  private Notifier getNotifier() {
1207
    return NOTIFIER;
1208
  }
1209
1210
  //---- Persistence accessors ----------------------------------------------
76
import org.reactfx.value.Val;
77
78
import java.nio.file.Path;
79
import java.util.HashMap;
80
import java.util.Map;
81
import java.util.Observable;
82
import java.util.Observer;
83
import java.util.function.Function;
84
import java.util.prefs.Preferences;
85
86
import static com.scrivenvar.Constants.*;
87
import static com.scrivenvar.Messages.get;
88
import static com.scrivenvar.util.StageState.*;
89
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
90
import static javafx.event.Event.fireEvent;
91
import static javafx.scene.input.KeyCode.ENTER;
92
import static javafx.scene.input.KeyCode.TAB;
93
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
94
95
/**
96
 * Main window containing a tab pane in the center for file editors.
97
 *
98
 * @author Karl Tauber and White Magic Software, Ltd.
99
 */
100
public class MainWindow implements Observer {
101
  /**
102
   * The {@code OPTIONS} variable must be declared before all other variables
103
   * to prevent subsequent initializations from failing due to missing user
104
   * preferences.
105
   */
106
  private final static Options OPTIONS = Services.load( Options.class );
107
  private final static Snitch SNITCH = Services.load( Snitch.class );
108
  private final static Notifier NOTIFIER = Services.load( Notifier.class );
109
110
  private final Scene mScene;
111
  private final StatusBar mStatusBar;
112
  private final Text mLineNumberText;
113
  private final TextField mFindTextField;
114
115
  private final Object mMutex = new Object();
116
117
  /**
118
   * Prevents re-instantiation of processing classes.
119
   */
120
  private final Map<FileEditorTab, Processor<String>> mProcessors =
121
      new HashMap<>();
122
123
  private final Map<String, String> mResolvedMap =
124
      new HashMap<>( DEFAULT_MAP_SIZE );
125
126
  /**
127
   * Called when the definition data is changed.
128
   */
129
  private final EventHandler<TreeItem.TreeModificationEvent<Event>>
130
      mTreeHandler = event -> {
131
    exportDefinitions( getDefinitionPath() );
132
    interpolateResolvedMap();
133
    renderActiveTab();
134
  };
135
136
  /**
137
   * Called to switch to the definition pane when the user presses the TAB key.
138
   */
139
  private final EventHandler<? super KeyEvent> mTabKeyHandler =
140
      (EventHandler<KeyEvent>) event -> {
141
        if( event.getCode() == TAB ) {
142
          getDefinitionPane().requestFocus();
143
          event.consume();
144
        }
145
      };
146
147
  /**
148
   * Called to inject the selected item when the user presses ENTER in the
149
   * definition pane.
150
   */
151
  private final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
152
      event -> {
153
        if( event.getCode() == ENTER ) {
154
          getVariableNameInjector().injectSelectedItem();
155
        }
156
      };
157
158
  private final ChangeListener<Integer> mCaretPositionListener =
159
      ( observable, oldPosition, newPosition ) -> {
160
        final FileEditorTab tab = getActiveFileEditorTab();
161
        final EditorPane pane = tab.getEditorPane();
162
        final StyleClassedTextArea editor = pane.getEditor();
163
164
        getLineNumberText().setText(
165
            get( STATUS_BAR_LINE,
166
                 editor.getCurrentParagraph() + 1,
167
                 editor.getParagraphs().size(),
168
                 editor.getCaretPosition()
169
            )
170
        );
171
      };
172
173
  private final ChangeListener<Integer> mCaretParagraphListener =
174
      ( observable, oldIndex, newIndex ) ->
175
          scrollToParagraph( newIndex, true );
176
177
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
178
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
179
  private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane();
180
  private final FileEditorTabPane mFileEditorPane = new FileEditorTabPane(
181
      mCaretPositionListener,
182
      mCaretParagraphListener );
183
184
  /**
185
   * Listens on the definition pane for double-click events.
186
   */
187
  private final VariableNameInjector mVariableNameInjector
188
      = new VariableNameInjector( mDefinitionPane );
189
190
  public MainWindow() {
191
    mStatusBar = createStatusBar();
192
    mLineNumberText = createLineNumberText();
193
    mFindTextField = createFindTextField();
194
    mScene = createScene();
195
196
    initLayout();
197
    initFindInput();
198
    initSnitch();
199
    initDefinitionListener();
200
    initTabAddedListener();
201
    initTabChangedListener();
202
    initPreferences();
203
    initVariableNameInjector();
204
205
    NOTIFIER.addObserver( this );
206
  }
207
208
  private void initLayout() {
209
    final Scene appScene = getScene();
210
211
    appScene.getStylesheets().add( STYLESHEET_SCENE );
212
213
    // TODO: Apply an XML syntax highlighting for XML files.
214
//    appScene.getStylesheets().add( STYLESHEET_XML );
215
    appScene.windowProperty().addListener(
216
        ( observable, oldWindow, newWindow ) ->
217
            newWindow.setOnCloseRequest(
218
                e -> {
219
                  if( !getFileEditorPane().closeAllEditors() ) {
220
                    e.consume();
221
                  }
222
                }
223
            )
224
    );
225
  }
226
227
  /**
228
   * Initialize the find input text field to listen on F3, ENTER, and
229
   * ESCAPE key presses.
230
   */
231
  private void initFindInput() {
232
    final TextField input = getFindTextField();
233
234
    input.setOnKeyPressed( ( KeyEvent event ) -> {
235
      switch( event.getCode() ) {
236
        case F3:
237
        case ENTER:
238
          editFindNext();
239
          break;
240
        case F:
241
          if( !event.isControlDown() ) {
242
            break;
243
          }
244
        case ESCAPE:
245
          getStatusBar().setGraphic( null );
246
          getActiveFileEditorTab().getEditorPane().requestFocus();
247
          break;
248
      }
249
    } );
250
251
    // Remove when the input field loses focus.
252
    input.focusedProperty().addListener(
253
        ( focused, oldFocus, newFocus ) -> {
254
          if( !newFocus ) {
255
            getStatusBar().setGraphic( null );
256
          }
257
        }
258
    );
259
  }
260
261
  /**
262
   * Watch for changes to external files. In particular, this awaits
263
   * modifications to any XSL files associated with XML files being edited.
264
   * When
265
   * an XSL file is modified (external to the application), the snitch's ears
266
   * perk up and the file is reloaded. This keeps the XSL transformation up to
267
   * date with what's on the file system.
268
   */
269
  private void initSnitch() {
270
    SNITCH.addObserver( this );
271
  }
272
273
  /**
274
   * Listen for {@link FileEditorTabPane} to receive open definition file
275
   * event.
276
   */
277
  private void initDefinitionListener() {
278
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
279
        ( final ObservableValue<? extends Path> file,
280
          final Path oldPath, final Path newPath ) -> {
281
          // Indirectly refresh the resolved map.
282
          resetProcessors();
283
284
          openDefinitions( newPath );
285
286
          // Will create new processors and therefore a new resolved map.
287
          renderActiveTab();
288
        }
289
    );
290
  }
291
292
  /**
293
   * When tabs are added, hook the various change listeners onto the new
294
   * tab sothat the preview pane refreshes as necessary.
295
   */
296
  private void initTabAddedListener() {
297
    final FileEditorTabPane editorPane = getFileEditorPane();
298
299
    // Make sure the text processor kicks off when new files are opened.
300
    final ObservableList<Tab> tabs = editorPane.getTabs();
301
302
    // Update the preview pane on tab changes.
303
    tabs.addListener(
304
        ( final Change<? extends Tab> change ) -> {
305
          while( change.next() ) {
306
            if( change.wasAdded() ) {
307
              // Multiple tabs can be added simultaneously.
308
              for( final Tab newTab : change.getAddedSubList() ) {
309
                final FileEditorTab tab = (FileEditorTab) newTab;
310
311
                initTextChangeListener( tab );
312
                initTabKeyEventListener( tab );
313
                initScrollEventListener( tab );
314
//              initSyntaxListener( tab );
315
              }
316
            }
317
          }
318
        }
319
    );
320
  }
321
322
  private void initScrollEventListener( final FileEditorTab tab ) {
323
    final var scrollPane = tab.getEditorPane().getScrollPane();
324
    final var scrollBar = getPreviewPane().getVerticalScrollBar();
325
326
    // Before the drag handler can be attached, the scroll bar for the
327
    // text editor pane must be visible.
328
    final ChangeListener<? super Boolean> listener = ( o, oldShow, newShow ) ->
329
        Platform.runLater( () -> {
330
          if( newShow ) {
331
            final var handler = new ScrollEventHandler( scrollPane, scrollBar );
332
            handler.enabledProperty().bind( tab.selectedProperty() );
333
          }
334
        } );
335
336
    Val.flatMap( scrollPane.sceneProperty(), Scene::windowProperty )
337
       .flatMap( Window::showingProperty )
338
       .addListener( listener );
339
  }
340
341
  /**
342
   * Listen for new tab selection events.
343
   */
344
  private void initTabChangedListener() {
345
    final FileEditorTabPane editorPane = getFileEditorPane();
346
347
    // Update the preview pane changing tabs.
348
    editorPane.addTabSelectionListener(
349
        ( tabPane, oldTab, newTab ) -> {
350
          // If there was no old tab, then this is a first time load, which
351
          // can be ignored.
352
          if( oldTab != null ) {
353
            if( newTab != null ) {
354
              final FileEditorTab tab = (FileEditorTab) newTab;
355
              updateVariableNameInjector( tab );
356
              process( tab );
357
            }
358
          }
359
        }
360
    );
361
  }
362
363
  /**
364
   * Reloads the preferences from the previous session.
365
   */
366
  private void initPreferences() {
367
    initDefinitionPane();
368
    getFileEditorPane().initPreferences();
369
  }
370
371
  private void initVariableNameInjector() {
372
    updateVariableNameInjector( getActiveFileEditorTab() );
373
  }
374
375
  /**
376
   * Ensure that the keyboard events are received when a new tab is added
377
   * to the user interface.
378
   *
379
   * @param tab The tab editor that can trigger keyboard events.
380
   */
381
  private void initTabKeyEventListener( final FileEditorTab tab ) {
382
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mTabKeyHandler );
383
  }
384
385
  private void initTextChangeListener( final FileEditorTab tab ) {
386
    tab.addTextChangeListener(
387
        ( editor, oldValue, newValue ) -> {
388
          process( tab );
389
          scrollToParagraph( getCurrentParagraphIndex() );
390
        }
391
    );
392
  }
393
394
  private int getCurrentParagraphIndex() {
395
    return getActiveEditorPane().getCurrentParagraphIndex();
396
  }
397
398
  private void scrollToParagraph( final int id ) {
399
    scrollToParagraph( id, false );
400
  }
401
402
  /**
403
   * @param id    The paragraph to scroll to, will be approximated if it doesn't
404
   *              exist.
405
   * @param force {@code true} means to force scrolling immediately, which
406
   *              should only be attempted when it is known that the document
407
   *              has been fully rendered. Otherwise the internal map of ID
408
   *              attributes will be incomplete and scrolling will flounder.
409
   */
410
  private void scrollToParagraph( final int id, final boolean force ) {
411
    synchronized( mMutex ) {
412
      final var previewPane = getPreviewPane();
413
      final var scrollPane = previewPane.getScrollPane();
414
      final int approxId = getActiveEditorPane().approximateParagraphId( id );
415
416
      if( force ) {
417
        previewPane.scrollTo( approxId );
418
      }
419
      else {
420
        previewPane.tryScrollTo( approxId );
421
      }
422
423
      scrollPane.repaint();
424
    }
425
  }
426
427
  private void updateVariableNameInjector( final FileEditorTab tab ) {
428
    getVariableNameInjector().addListener( tab );
429
  }
430
431
  /**
432
   * Called whenever the preview pane becomes out of sync with the file editor
433
   * tab. This can be called when the text changes, the caret paragraph
434
   * changes,
435
   * or the file tab changes.
436
   *
437
   * @param tab The file editor tab that has been changed in some fashion.
438
   */
439
  private void process( final FileEditorTab tab ) {
440
    if( tab == null ) {
441
      return;
442
    }
443
444
    getPreviewPane().setPath( tab.getPath() );
445
446
    final Processor<String> processor = getProcessors().computeIfAbsent(
447
        tab, p -> createProcessor( tab )
448
    );
449
450
    try {
451
      processor.processChain( tab.getEditorText() );
452
    } catch( final Exception ex ) {
453
      error( ex );
454
    }
455
  }
456
457
  private void renderActiveTab() {
458
    process( getActiveFileEditorTab() );
459
  }
460
461
  /**
462
   * Called when a definition source is opened.
463
   *
464
   * @param path Path to the definition source that was opened.
465
   */
466
  private void openDefinitions( final Path path ) {
467
    try {
468
      final DefinitionSource ds = createDefinitionSource( path );
469
      setDefinitionSource( ds );
470
      getUserPreferences().definitionPathProperty().setValue( path.toFile() );
471
      getUserPreferences().save();
472
473
      final Tooltip tooltipPath = new Tooltip( path.toString() );
474
      tooltipPath.setShowDelay( Duration.millis( 200 ) );
475
476
      final DefinitionPane pane = getDefinitionPane();
477
      pane.update( ds );
478
      pane.addTreeChangeHandler( mTreeHandler );
479
      pane.addKeyEventHandler( mDefinitionKeyHandler );
480
      pane.filenameProperty().setValue( path.getFileName().toString() );
481
      pane.setTooltip( tooltipPath );
482
483
      interpolateResolvedMap();
484
    } catch( final Exception e ) {
485
      error( e );
486
    }
487
  }
488
489
  private void exportDefinitions( final Path path ) {
490
    try {
491
      final DefinitionPane pane = getDefinitionPane();
492
      final TreeItem<String> root = pane.getTreeView().getRoot();
493
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
494
495
      if( problemChild == null ) {
496
        getDefinitionSource().getTreeAdapter().export( root, path );
497
        getNotifier().clear();
498
      }
499
      else {
500
        final String msg = get(
501
            "yaml.error.tree.form", problemChild.getValue() );
502
        getNotifier().notify( msg );
503
      }
504
    } catch( final Exception e ) {
505
      error( e );
506
    }
507
  }
508
509
  private void interpolateResolvedMap() {
510
    final Map<String, String> treeMap = getDefinitionPane().toMap();
511
    final Map<String, String> map = new HashMap<>( treeMap );
512
    MapInterpolator.interpolate( map );
513
514
    getResolvedMap().clear();
515
    getResolvedMap().putAll( map );
516
  }
517
518
  private void initDefinitionPane() {
519
    openDefinitions( getDefinitionPath() );
520
  }
521
522
  /**
523
   * Called when an exception occurs that warrants the user's attention.
524
   *
525
   * @param e The exception with a message that the user should know about.
526
   */
527
  private void error( final Exception e ) {
528
    getNotifier().notify( e );
529
  }
530
531
  //---- File actions -------------------------------------------------------
532
533
  /**
534
   * Called when an {@link Observable} instance has changed. This is called
535
   * by both the {@link Snitch} service and the notify service. The @link
536
   * Snitch} service can be called for different file types, including
537
   * {@link DefinitionSource} instances.
538
   *
539
   * @param observable The observed instance.
540
   * @param value      The noteworthy item.
541
   */
542
  @Override
543
  public void update( final Observable observable, final Object value ) {
544
    if( value != null ) {
545
      if( observable instanceof Snitch && value instanceof Path ) {
546
        updateSelectedTab();
547
      }
548
      else if( observable instanceof Notifier && value instanceof String ) {
549
        updateStatusBar( (String) value );
550
      }
551
    }
552
  }
553
554
  /**
555
   * Updates the status bar to show the given message.
556
   *
557
   * @param s The message to show in the status bar.
558
   */
559
  private void updateStatusBar( final String s ) {
560
    Platform.runLater(
561
        () -> {
562
          final int index = s.indexOf( '\n' );
563
          final String message = s.substring(
564
              0, index > 0 ? index : s.length() );
565
566
          getStatusBar().setText( message );
567
        }
568
    );
569
  }
570
571
  /**
572
   * Called when a file has been modified.
573
   */
574
  private void updateSelectedTab() {
575
    Platform.runLater(
576
        () -> {
577
          // Brute-force XSLT file reload by re-instantiating all processors.
578
          resetProcessors();
579
          renderActiveTab();
580
        }
581
    );
582
  }
583
584
  /**
585
   * After resetting the processors, they will refresh anew to be up-to-date
586
   * with the files (text and definition) currently loaded into the editor.
587
   */
588
  private void resetProcessors() {
589
    getProcessors().clear();
590
  }
591
592
  //---- File actions -------------------------------------------------------
593
594
  private void fileNew() {
595
    getFileEditorPane().newEditor();
596
  }
597
598
  private void fileOpen() {
599
    getFileEditorPane().openFileDialog();
600
  }
601
602
  private void fileClose() {
603
    getFileEditorPane().closeEditor( getActiveFileEditorTab(), true );
604
  }
605
606
  /**
607
   * TODO: Upon closing, first remove the tab change listeners. (There's no
608
   * need to re-render each tab when all are being closed.)
609
   */
610
  private void fileCloseAll() {
611
    getFileEditorPane().closeAllEditors();
612
  }
613
614
  private void fileSave() {
615
    getFileEditorPane().saveEditor( getActiveFileEditorTab() );
616
  }
617
618
  private void fileSaveAs() {
619
    final FileEditorTab editor = getActiveFileEditorTab();
620
    getFileEditorPane().saveEditorAs( editor );
621
    getProcessors().remove( editor );
622
623
    try {
624
      process( editor );
625
    } catch( final Exception ex ) {
626
      getNotifier().notify( ex );
627
    }
628
  }
629
630
  private void fileSaveAll() {
631
    getFileEditorPane().saveAllEditors();
632
  }
633
634
  private void fileExit() {
635
    final Window window = getWindow();
636
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
637
  }
638
639
  //---- Edit actions -------------------------------------------------------
640
641
  /**
642
   * Used to find text in the active file editor window.
643
   */
644
  private void editFind() {
645
    final TextField input = getFindTextField();
646
    getStatusBar().setGraphic( input );
647
    input.requestFocus();
648
  }
649
650
  public void editFindNext() {
651
    getActiveFileEditorTab().searchNext( getFindTextField().getText() );
652
  }
653
654
  public void editPreferences() {
655
    getUserPreferences().show();
656
  }
657
658
  //---- Insert actions -----------------------------------------------------
659
660
  /**
661
   * Delegates to the active editor to handle wrapping the current text
662
   * selection with leading and trailing strings.
663
   *
664
   * @param leading  The string to put before the selection.
665
   * @param trailing The string to put after the selection.
666
   */
667
  private void insertMarkdown(
668
      final String leading, final String trailing ) {
669
    getActiveEditorPane().surroundSelection( leading, trailing );
670
  }
671
672
  @SuppressWarnings("SameParameterValue")
673
  private void insertMarkdown(
674
      final String leading, final String trailing, final String hint ) {
675
    getActiveEditorPane().surroundSelection( leading, trailing, hint );
676
  }
677
678
  //---- Help actions -------------------------------------------------------
679
680
  private void helpAbout() {
681
    final Alert alert = new Alert( AlertType.INFORMATION );
682
    alert.setTitle( get( "Dialog.about.title" ) );
683
    alert.setHeaderText( get( "Dialog.about.header" ) );
684
    alert.setContentText( get( "Dialog.about.content" ) );
685
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
686
    alert.initOwner( getWindow() );
687
688
    alert.showAndWait();
689
  }
690
691
  //---- Member creators ----------------------------------------------------
692
693
  /**
694
   * Factory to create processors that are suited to different file types.
695
   *
696
   * @param tab The tab that is subjected to processing.
697
   * @return A processor suited to the file type specified by the tab's path.
698
   */
699
  private Processor<String> createProcessor( final FileEditorTab tab ) {
700
    return createProcessorFactory().createProcessor( tab );
701
  }
702
703
  private ProcessorFactory createProcessorFactory() {
704
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
705
  }
706
707
  private HTMLPreviewPane createHTMLPreviewPane() {
708
    return new HTMLPreviewPane();
709
  }
710
711
  private DefinitionSource createDefaultDefinitionSource() {
712
    return new YamlDefinitionSource( getDefinitionPath() );
713
  }
714
715
  private DefinitionSource createDefinitionSource( final Path path ) {
716
    try {
717
      return createDefinitionFactory().createDefinitionSource( path );
718
    } catch( final Exception ex ) {
719
      error( ex );
720
      return createDefaultDefinitionSource();
721
    }
722
  }
723
724
  private TextField createFindTextField() {
725
    return new TextField();
726
  }
727
728
  private DefinitionFactory createDefinitionFactory() {
729
    return new DefinitionFactory();
730
  }
731
732
  private StatusBar createStatusBar() {
733
    return new StatusBar();
734
  }
735
736
  private Scene createScene() {
737
    final SplitPane splitPane = new SplitPane(
738
        getDefinitionPane().getNode(),
739
        getFileEditorPane().getNode(),
740
        getPreviewPane().getNode() );
741
742
    splitPane.setDividerPositions(
743
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
744
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
745
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
746
747
    getDefinitionPane().prefHeightProperty()
748
                       .bind( splitPane.heightProperty() );
749
750
    final BorderPane borderPane = new BorderPane();
751
    borderPane.setPrefSize( 1024, 800 );
752
    borderPane.setTop( createMenuBar() );
753
    borderPane.setBottom( getStatusBar() );
754
    borderPane.setCenter( splitPane );
755
756
    final VBox statusBar = new VBox();
757
    statusBar.setAlignment( Pos.BASELINE_CENTER );
758
    statusBar.getChildren().add( getLineNumberText() );
759
    getStatusBar().getRightItems().add( statusBar );
760
761
    return new Scene( borderPane );
762
  }
763
764
  private Text createLineNumberText() {
765
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
766
  }
767
768
  private Node createMenuBar() {
769
    final BooleanBinding activeFileEditorIsNull =
770
        getFileEditorPane().activeFileEditorProperty().isNull();
771
772
    // File actions
773
    final Action fileNewAction = new ActionBuilder()
774
        .setText( "Main.menu.file.new" )
775
        .setAccelerator( "Shortcut+N" )
776
        .setIcon( FILE_ALT )
777
        .setAction( e -> fileNew() )
778
        .build();
779
    final Action fileOpenAction = new ActionBuilder()
780
        .setText( "Main.menu.file.open" )
781
        .setAccelerator( "Shortcut+O" )
782
        .setIcon( FOLDER_OPEN_ALT )
783
        .setAction( e -> fileOpen() )
784
        .build();
785
    final Action fileCloseAction = new ActionBuilder()
786
        .setText( "Main.menu.file.close" )
787
        .setAccelerator( "Shortcut+W" )
788
        .setAction( e -> fileClose() )
789
        .setDisable( activeFileEditorIsNull )
790
        .build();
791
    final Action fileCloseAllAction = new ActionBuilder()
792
        .setText( "Main.menu.file.close_all" )
793
        .setAction( e -> fileCloseAll() )
794
        .setDisable( activeFileEditorIsNull )
795
        .build();
796
    final Action fileSaveAction = new ActionBuilder()
797
        .setText( "Main.menu.file.save" )
798
        .setAccelerator( "Shortcut+S" )
799
        .setIcon( FLOPPY_ALT )
800
        .setAction( e -> fileSave() )
801
        .setDisable( createActiveBooleanProperty(
802
            FileEditorTab::modifiedProperty ).not() )
803
        .build();
804
    final Action fileSaveAsAction = new ActionBuilder()
805
        .setText( "Main.menu.file.save_as" )
806
        .setAction( e -> fileSaveAs() )
807
        .setDisable( activeFileEditorIsNull )
808
        .build();
809
    final Action fileSaveAllAction = new ActionBuilder()
810
        .setText( "Main.menu.file.save_all" )
811
        .setAccelerator( "Shortcut+Shift+S" )
812
        .setAction( e -> fileSaveAll() )
813
        .setDisable( Bindings.not(
814
            getFileEditorPane().anyFileEditorModifiedProperty() ) )
815
        .build();
816
    final Action fileExitAction = new ActionBuilder()
817
        .setText( "Main.menu.file.exit" )
818
        .setAction( e -> fileExit() )
819
        .build();
820
821
    // Edit actions
822
    final Action editUndoAction = new ActionBuilder()
823
        .setText( "Main.menu.edit.undo" )
824
        .setAccelerator( "Shortcut+Z" )
825
        .setIcon( UNDO )
826
        .setAction( e -> getActiveEditorPane().undo() )
827
        .setDisable( createActiveBooleanProperty(
828
            FileEditorTab::canUndoProperty ).not() )
829
        .build();
830
    final Action editRedoAction = new ActionBuilder()
831
        .setText( "Main.menu.edit.redo" )
832
        .setAccelerator( "Shortcut+Y" )
833
        .setIcon( REPEAT )
834
        .setAction( e -> getActiveEditorPane().redo() )
835
        .setDisable( createActiveBooleanProperty(
836
            FileEditorTab::canRedoProperty ).not() )
837
        .build();
838
    final Action editFindAction = new ActionBuilder()
839
        .setText( "Main.menu.edit.find" )
840
        .setAccelerator( "Ctrl+F" )
841
        .setIcon( SEARCH )
842
        .setAction( e -> editFind() )
843
        .setDisable( activeFileEditorIsNull )
844
        .build();
845
    final Action editFindNextAction = new ActionBuilder()
846
        .setText( "Main.menu.edit.find.next" )
847
        .setAccelerator( "F3" )
848
        .setIcon( null )
849
        .setAction( e -> editFindNext() )
850
        .setDisable( activeFileEditorIsNull )
851
        .build();
852
    final Action editPreferencesAction = new ActionBuilder()
853
        .setText( "Main.menu.edit.preferences" )
854
        .setAccelerator( "Ctrl+Alt+S" )
855
        .setAction( e -> editPreferences() )
856
        .build();
857
858
    // Insert actions
859
    final Action insertBoldAction = new ActionBuilder()
860
        .setText( "Main.menu.insert.bold" )
861
        .setAccelerator( "Shortcut+B" )
862
        .setIcon( BOLD )
863
        .setAction( e -> insertMarkdown( "**", "**" ) )
864
        .setDisable( activeFileEditorIsNull )
865
        .build();
866
    final Action insertItalicAction = new ActionBuilder()
867
        .setText( "Main.menu.insert.italic" )
868
        .setAccelerator( "Shortcut+I" )
869
        .setIcon( ITALIC )
870
        .setAction( e -> insertMarkdown( "*", "*" ) )
871
        .setDisable( activeFileEditorIsNull )
872
        .build();
873
    final Action insertSuperscriptAction = new ActionBuilder()
874
        .setText( "Main.menu.insert.superscript" )
875
        .setAccelerator( "Shortcut+[" )
876
        .setIcon( SUPERSCRIPT )
877
        .setAction( e -> insertMarkdown( "^", "^" ) )
878
        .setDisable( activeFileEditorIsNull )
879
        .build();
880
    final Action insertSubscriptAction = new ActionBuilder()
881
        .setText( "Main.menu.insert.subscript" )
882
        .setAccelerator( "Shortcut+]" )
883
        .setIcon( SUBSCRIPT )
884
        .setAction( e -> insertMarkdown( "~", "~" ) )
885
        .setDisable( activeFileEditorIsNull )
886
        .build();
887
    final Action insertStrikethroughAction = new ActionBuilder()
888
        .setText( "Main.menu.insert.strikethrough" )
889
        .setAccelerator( "Shortcut+T" )
890
        .setIcon( STRIKETHROUGH )
891
        .setAction( e -> insertMarkdown( "~~", "~~" ) )
892
        .setDisable( activeFileEditorIsNull )
893
        .build();
894
    final Action insertBlockquoteAction = new ActionBuilder()
895
        .setText( "Main.menu.insert.blockquote" )
896
        .setAccelerator( "Ctrl+Q" )
897
        .setIcon( QUOTE_LEFT )
898
        .setAction( e -> insertMarkdown( "\n\n> ", "" ) )
899
        .setDisable( activeFileEditorIsNull )
900
        .build();
901
    final Action insertCodeAction = new ActionBuilder()
902
        .setText( "Main.menu.insert.code" )
903
        .setAccelerator( "Shortcut+K" )
904
        .setIcon( CODE )
905
        .setAction( e -> insertMarkdown( "`", "`" ) )
906
        .setDisable( activeFileEditorIsNull )
907
        .build();
908
    final Action insertFencedCodeBlockAction = new ActionBuilder()
909
        .setText( "Main.menu.insert.fenced_code_block" )
910
        .setAccelerator( "Shortcut+Shift+K" )
911
        .setIcon( FILE_CODE_ALT )
912
        .setAction( e -> getActiveEditorPane().surroundSelection(
913
            "\n\n```\n",
914
            "\n```\n\n",
915
            get( "Main.menu.insert.fenced_code_block.prompt" ) ) )
916
        .setDisable( activeFileEditorIsNull )
917
        .build();
918
    final Action insertLinkAction = new ActionBuilder()
919
        .setText( "Main.menu.insert.link" )
920
        .setAccelerator( "Shortcut+L" )
921
        .setIcon( LINK )
922
        .setAction( e -> getActiveEditorPane().insertLink() )
923
        .setDisable( activeFileEditorIsNull )
924
        .build();
925
    final Action insertImageAction = new ActionBuilder()
926
        .setText( "Main.menu.insert.image" )
927
        .setAccelerator( "Shortcut+G" )
928
        .setIcon( PICTURE_ALT )
929
        .setAction( e -> getActiveEditorPane().insertImage() )
930
        .setDisable( activeFileEditorIsNull )
931
        .build();
932
933
    // Number of header actions (H1 ... H3)
934
    final int HEADERS = 3;
935
    final Action[] headers = new Action[ HEADERS ];
936
937
    for( int i = 1; i <= HEADERS; i++ ) {
938
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
939
      final String markup = String.format( "%n%n%s ", hashes );
940
      final String text = "Main.menu.insert.header." + i;
941
      final String accelerator = "Shortcut+" + i;
942
      final String prompt = text + ".prompt";
943
944
      headers[ i - 1 ] = new ActionBuilder()
945
          .setText( text )
946
          .setAccelerator( accelerator )
947
          .setIcon( HEADER )
948
          .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) )
949
          .setDisable( activeFileEditorIsNull )
950
          .build();
951
    }
952
953
    final Action insertUnorderedListAction = new ActionBuilder()
954
        .setText( "Main.menu.insert.unordered_list" )
955
        .setAccelerator( "Shortcut+U" )
956
        .setIcon( LIST_UL )
957
        .setAction( e -> getActiveEditorPane()
958
            .surroundSelection( "\n\n* ", "" ) )
959
        .setDisable( activeFileEditorIsNull )
960
        .build();
961
    final Action insertOrderedListAction = new ActionBuilder()
962
        .setText( "Main.menu.insert.ordered_list" )
963
        .setAccelerator( "Shortcut+Shift+O" )
964
        .setIcon( LIST_OL )
965
        .setAction( e -> insertMarkdown(
966
            "\n\n1. ", "" ) )
967
        .setDisable( activeFileEditorIsNull )
968
        .build();
969
    final Action insertHorizontalRuleAction = new ActionBuilder()
970
        .setText( "Main.menu.insert.horizontal_rule" )
971
        .setAccelerator( "Shortcut+H" )
972
        .setAction( e -> insertMarkdown(
973
            "\n\n---\n\n", "" ) )
974
        .setDisable( activeFileEditorIsNull )
975
        .build();
976
977
    // Help actions
978
    final Action helpAboutAction = new ActionBuilder()
979
        .setText( "Main.menu.help.about" )
980
        .setAction( e -> helpAbout() )
981
        .build();
982
983
    //---- MenuBar ----
984
    final Menu fileMenu = ActionUtils.createMenu(
985
        get( "Main.menu.file" ),
986
        fileNewAction,
987
        fileOpenAction,
988
        null,
989
        fileCloseAction,
990
        fileCloseAllAction,
991
        null,
992
        fileSaveAction,
993
        fileSaveAsAction,
994
        fileSaveAllAction,
995
        null,
996
        fileExitAction );
997
998
    final Menu editMenu = ActionUtils.createMenu(
999
        get( "Main.menu.edit" ),
1000
        editUndoAction,
1001
        editRedoAction,
1002
        editFindAction,
1003
        editFindNextAction,
1004
        null,
1005
        editPreferencesAction );
1006
1007
    final Menu insertMenu = ActionUtils.createMenu(
1008
        get( "Main.menu.insert" ),
1009
        insertBoldAction,
1010
        insertItalicAction,
1011
        insertSuperscriptAction,
1012
        insertSubscriptAction,
1013
        insertStrikethroughAction,
1014
        insertBlockquoteAction,
1015
        insertCodeAction,
1016
        insertFencedCodeBlockAction,
1017
        null,
1018
        insertLinkAction,
1019
        insertImageAction,
1020
        null,
1021
        headers[ 0 ],
1022
        headers[ 1 ],
1023
        headers[ 2 ],
1024
        null,
1025
        insertUnorderedListAction,
1026
        insertOrderedListAction,
1027
        insertHorizontalRuleAction );
1028
1029
    final Menu helpMenu = ActionUtils.createMenu(
1030
        get( "Main.menu.help" ),
1031
        helpAboutAction );
1032
1033
    final MenuBar menuBar = new MenuBar(
1034
        fileMenu,
1035
        editMenu,
1036
        insertMenu,
1037
        helpMenu );
1038
1039
    //---- ToolBar ----
1040
    final ToolBar toolBar = ActionUtils.createToolBar(
1041
        fileNewAction,
1042
        fileOpenAction,
1043
        fileSaveAction,
1044
        null,
1045
        editUndoAction,
1046
        editRedoAction,
1047
        null,
1048
        insertBoldAction,
1049
        insertItalicAction,
1050
        insertSuperscriptAction,
1051
        insertSubscriptAction,
1052
        insertBlockquoteAction,
1053
        insertCodeAction,
1054
        insertFencedCodeBlockAction,
1055
        null,
1056
        insertLinkAction,
1057
        insertImageAction,
1058
        null,
1059
        headers[ 0 ],
1060
        null,
1061
        insertUnorderedListAction,
1062
        insertOrderedListAction );
1063
1064
    return new VBox( menuBar, toolBar );
1065
  }
1066
1067
  /**
1068
   * Creates a boolean property that is bound to another boolean value of the
1069
   * active editor.
1070
   */
1071
  private BooleanProperty createActiveBooleanProperty(
1072
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1073
1074
    final BooleanProperty b = new SimpleBooleanProperty();
1075
    final FileEditorTab tab = getActiveFileEditorTab();
1076
1077
    if( tab != null ) {
1078
      b.bind( func.apply( tab ) );
1079
    }
1080
1081
    getFileEditorPane().activeFileEditorProperty().addListener(
1082
        ( observable, oldFileEditor, newFileEditor ) -> {
1083
          b.unbind();
1084
1085
          if( newFileEditor == null ) {
1086
            b.set( false );
1087
          }
1088
          else {
1089
            b.bind( func.apply( newFileEditor ) );
1090
          }
1091
        }
1092
    );
1093
1094
    return b;
1095
  }
1096
1097
  //---- Convenience accessors ----------------------------------------------
1098
1099
  private Preferences getPreferences() {
1100
    return OPTIONS.getState();
1101
  }
1102
1103
  private float getFloat( final String key, final float defaultValue ) {
1104
    return getPreferences().getFloat( key, defaultValue );
1105
  }
1106
1107
  public Window getWindow() {
1108
    return getScene().getWindow();
1109
  }
1110
1111
  private MarkdownEditorPane getActiveEditorPane() {
1112
    return getActiveFileEditorTab().getEditorPane();
1113
  }
1114
1115
  private FileEditorTab getActiveFileEditorTab() {
1116
    return getFileEditorPane().getActiveFileEditor();
1117
  }
1118
1119
  //---- Member accessors ---------------------------------------------------
1120
1121
  protected Scene getScene() {
1122
    return mScene;
1123
  }
1124
1125
  private Map<FileEditorTab, Processor<String>> getProcessors() {
1126
    return mProcessors;
1127
  }
1128
1129
  private FileEditorTabPane getFileEditorPane() {
1130
    return mFileEditorPane;
1131
  }
1132
1133
  private HTMLPreviewPane getPreviewPane() {
1134
    return mPreviewPane;
1135
  }
1136
1137
  private void setDefinitionSource(
1138
      final DefinitionSource definitionSource ) {
1139
    assert definitionSource != null;
1140
    mDefinitionSource = definitionSource;
1141
  }
1142
1143
  private DefinitionSource getDefinitionSource() {
1144
    return mDefinitionSource;
1145
  }
1146
1147
  private DefinitionPane getDefinitionPane() {
1148
    return mDefinitionPane;
1149
  }
1150
1151
  private Text getLineNumberText() {
1152
    return mLineNumberText;
1153
  }
1154
1155
  private StatusBar getStatusBar() {
1156
    return mStatusBar;
1157
  }
1158
1159
  private TextField getFindTextField() {
1160
    return mFindTextField;
1161
  }
1162
1163
  private VariableNameInjector getVariableNameInjector() {
1164
    return mVariableNameInjector;
1165
  }
1166
1167
  /**
1168
   * Returns the variable map of interpolated definitions.
1169
   *
1170
   * @return A map to help dereference variables.
1171
   */
1172
  private Map<String, String> getResolvedMap() {
1173
    return mResolvedMap;
1174
  }
1175
1176
  private Notifier getNotifier() {
1177
    return NOTIFIER;
1178
  }
1179
1180
  //---- Persistence accessors ----------------------------------------------
1181
12111182
  private UserPreferences getUserPreferences() {
12121183
    return OPTIONS.getUserPreferences();
A src/main/java/com/scrivenvar/ScrollEventHandler.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.scrivenvar;
29
30
import javafx.beans.property.BooleanProperty;
31
import javafx.beans.property.SimpleBooleanProperty;
32
import javafx.event.Event;
33
import javafx.event.EventHandler;
34
import javafx.scene.Node;
35
import javafx.scene.control.ScrollBar;
36
import javafx.scene.control.skin.ScrollBarSkin;
37
import javafx.scene.input.MouseEvent;
38
import javafx.scene.input.ScrollEvent;
39
import javafx.scene.layout.StackPane;
40
import org.fxmisc.flowless.VirtualizedScrollPane;
41
import org.fxmisc.richtext.StyleClassedTextArea;
42
43
import javax.swing.*;
44
45
import static javafx.geometry.Orientation.VERTICAL;
46
47
/**
48
 * Converts scroll events from {@link VirtualizedScrollPane} scroll bars to
49
 * an instance of {@link JScrollBar}.
50
 * <p>
51
 * Called to synchronize the scrolling areas for either scrolling with the
52
 * mouse or scrolling using the scrollbar's thumb. Both are required to avoid
53
 * scrolling on the estimatedScrollYProperty that occurs when text events
54
 * fire. Scrolling performed for text events are handled separately to ensure
55
 * the preview panel scrolls to the same position in the Markdown editor,
56
 * taking into account things like images, tables, and other potentially
57
 * long vertical presentation items.
58
 * </p>
59
 */
60
public final class ScrollEventHandler implements EventHandler<Event> {
61
62
  private final class MouseHandler implements EventHandler<MouseEvent> {
63
    private final EventHandler<? super MouseEvent> mOldHandler;
64
65
    /**
66
     * Constructs a new handler for mouse scrolling events.
67
     *
68
     * @param oldHandler Receives the event after scrolling takes place.
69
     */
70
    private MouseHandler( final EventHandler<? super MouseEvent> oldHandler ) {
71
      mOldHandler = oldHandler;
72
    }
73
74
    @Override
75
    public void handle( final MouseEvent event ) {
76
      ScrollEventHandler.this.handle( event );
77
      mOldHandler.handle( event );
78
    }
79
  }
80
81
  private final class ScrollHandler implements EventHandler<ScrollEvent> {
82
    @Override
83
    public void handle( final ScrollEvent event ) {
84
      ScrollEventHandler.this.handle( event );
85
    }
86
  }
87
88
  private final VirtualizedScrollPane<StyleClassedTextArea> mEditorScrollPane;
89
  private final JScrollBar mPreviewScrollBar;
90
  private final BooleanProperty mEnabled = new SimpleBooleanProperty();
91
92
  /**
93
   * @param editorScrollPane Scroll event source (human movement).
94
   * @param previewScrollBar Scroll event destination (corresponding movement).
95
   */
96
  public ScrollEventHandler(
97
      final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane,
98
      final JScrollBar previewScrollBar ) {
99
    mEditorScrollPane = editorScrollPane;
100
    mPreviewScrollBar = previewScrollBar;
101
102
    mEditorScrollPane.addEventFilter( ScrollEvent.ANY, new ScrollHandler() );
103
104
    final var thumb = getVerticalScrollBarThumb( mEditorScrollPane );
105
    thumb.setOnMouseDragged( new MouseHandler( thumb.getOnMouseDragged() ) );
106
  }
107
108
  /**
109
   * Gets a property intended to be bound to selected property of the tab being
110
   * scrolled. This is required because there's only one preview pane but
111
   * multiple editor panes. Each editor pane maintains its own scroll position.
112
   *
113
   * @return A {@link BooleanProperty} representing whether the scroll
114
   * events for this tab are to be executed.
115
   */
116
  public BooleanProperty enabledProperty() {
117
    return mEnabled;
118
  }
119
120
  /**
121
   * Scrolls the preview scrollbar relative to the edit scrollbar. Algorithm
122
   * is based on Karl Tauber's ratio calculation.
123
   *
124
   * @param event Unused; either {@link MouseEvent} or {@link ScrollEvent}
125
   */
126
  @Override
127
  public void handle( final Event event ) {
128
    if( isEnabled() ) {
129
      final var eScrollPane = getEditorScrollPane();
130
      final int eScrollY =
131
          eScrollPane.estimatedScrollYProperty().getValue().intValue();
132
      final int eHeight = (int)
133
          (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
134
              - eScrollPane.getHeight());
135
      final double eRatio = eHeight > 0
136
          ? Math.min( Math.max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
137
138
      final var pScrollBar = getPreviewScrollBar();
139
      final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
140
      final var pScrollY = (int) (pHeight * eRatio);
141
142
      pScrollBar.setValue( pScrollY );
143
      pScrollBar.getParent().repaint();
144
    }
145
  }
146
147
  private StackPane getVerticalScrollBarThumb(
148
      final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
149
    final ScrollBar scrollBar = getVerticalScrollBar( pane );
150
    final ScrollBarSkin skin = (ScrollBarSkin) (scrollBar.skinProperty().get());
151
152
    for( final Node node : skin.getChildren() ) {
153
      // Brittle, but what can you do?
154
      if( node.getStyleClass().contains( "thumb" ) ) {
155
        return (StackPane) node;
156
      }
157
    }
158
159
    throw new IllegalArgumentException( "No scroll bar skin found." );
160
  }
161
162
  private ScrollBar getVerticalScrollBar(
163
      final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
164
165
    for( final Node node : pane.getChildrenUnmodifiable() ) {
166
      if( node instanceof ScrollBar ) {
167
        final ScrollBar scrollBar = (ScrollBar) node;
168
169
        if( scrollBar.getOrientation() == VERTICAL ) {
170
          return scrollBar;
171
        }
172
      }
173
    }
174
175
    throw new IllegalArgumentException( "No vertical scroll pane found." );
176
  }
177
178
  private boolean isEnabled() {
179
    // As a minor optimization, when this is set to false, it could remove
180
    // the MouseHandler and ScrollHandler so that events only dispatch to one
181
    // object (instead of one per editor tab).
182
    return mEnabled.get();
183
  }
184
185
  private VirtualizedScrollPane<StyleClassedTextArea> getEditorScrollPane() {
186
    return mEditorScrollPane;
187
  }
188
189
  private JScrollBar getPreviewScrollBar() {
190
    return mPreviewScrollBar;
191
  }
192
}
1193
D src/main/java/com/scrivenvar/controls/WebHyperlink.java
1
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
package com.scrivenvar.controls;
28
29
import com.scrivenvar.Main;
30
import javafx.beans.property.SimpleStringProperty;
31
import javafx.beans.property.StringProperty;
32
import javafx.scene.control.Hyperlink;
33
34
/**
35
 * Opens a web site in the default web browser.
36
 *
37
 * @author Karl Tauber
38
 */
39
public class WebHyperlink extends Hyperlink {
40
41
  // 'uri' property
42
  private final StringProperty uri = new SimpleStringProperty();
43
44
  public WebHyperlink() {
45
    setStyle( "-fx-padding: 0; -fx-border-width: 0" );
46
  }
47
48
  @Override
49
  public void fire() {
50
    Main.showDocument( getUri() );
51
  }
52
53
  public String getUri() {
54
    return uri.get();
55
  }
56
57
  public void setUri( String uri ) {
58
    this.uri.set( uri );
59
  }
60
61
  public StringProperty uriProperty() {
62
    return uri;
63
  }
64
}
651
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
206206
  public VariableTreeItem<String> findLeaf(
207207
      final String value, final FindMode findMode ) {
208
    final VariableTreeItem<String> root = getTreeRoot();
209
    final VariableTreeItem<String> leaf = root.findLeaf( value, findMode );
208
    final var root = getTreeRoot();
209
    final var leaf = root.findLeaf( value, findMode );
210210
211211
    return leaf == null
...
266266
   */
267267
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
268
    for( final TreeItem<T> node : nodes ) {
268
    for( final var node : nodes ) {
269269
      node.setExpanded( false );
270270
      collapse( node.getChildren() );
...
290290
   */
291291
  private void deleteSelectedItems() {
292
    for( final TreeItem<String> item : getSelectedItems() ) {
293
      final TreeItem<String> parent = item.getParent();
292
    for( final var item : getSelectedItems() ) {
293
      final var parent = item.getParent();
294294
295295
      if( parent != null ) {
...
303303
   */
304304
  private void deleteSelectedItem() {
305
    final TreeItem<String> c = getSelectedItem();
305
    final var c = getSelectedItem();
306306
    getSiblings( c ).remove( c );
307307
  }
...
314314
   */
315315
  private void addItem() {
316
    final TreeItem<String> value = createTreeItem();
316
    final var value = createTreeItem();
317317
    getSelectedItem().getChildren().add( value );
318318
    expand( value );
...
466466
  private ObservableList<TreeItem<String>> getSiblings(
467467
      final TreeItem<String> item ) {
468
    final TreeItem<String> root = getTreeView().getRoot();
469
    final TreeItem<String> parent =
470
        (item == null || item == root) ? root : item.getParent();
468
    final var root = getTreeView().getRoot();
469
    final var parent = (item == null || item == root) ? root : item.getParent();
471470
472471
    return parent.getChildren();
...
488487
489488
  public TreeItem<String> getSelectedItem() {
490
    final TreeItem<String> item = getSelectionModel().getSelectedItem();
489
    final var item = getSelectionModel().getSelectedItem();
491490
    return item == null ? getTreeView().getRoot() : item;
492491
  }
M src/main/java/com/scrivenvar/definition/VariableTreeItem.java
114114
   */
115115
  private String getDiacriticlessValue() {
116
    final String value = getValue().toString();
117
    final String normalized = Normalizer.normalize( value, NFD );
118
119
    return normalized.replaceAll( "\\p{M}", "" );
116
    return Normalizer.normalize( getValue().toString(), NFD )
117
                     .replaceAll( "\\p{M}", "" );
120118
  }
121119
M src/main/java/com/scrivenvar/editors/EditorPane.java
2828
package com.scrivenvar.editors;
2929
30
import com.scrivenvar.AbstractPane;
3130
import javafx.application.Platform;
3231
import javafx.beans.property.ObjectProperty;
3332
import javafx.beans.property.SimpleObjectProperty;
3433
import javafx.beans.value.ChangeListener;
3534
import javafx.event.Event;
3635
import javafx.scene.control.ScrollPane;
36
import javafx.scene.layout.Pane;
3737
import org.fxmisc.flowless.VirtualizedScrollPane;
3838
import org.fxmisc.richtext.StyleClassedTextArea;
...
5151
 * @author White Magic Software, Ltd.
5252
 */
53
public class EditorPane extends AbstractPane {
53
public class EditorPane extends Pane {
5454
5555
  private final StyleClassedTextArea mEditor =
...
102102
103103
  /**
104
   * Call to listen for when the caret moves to another paragraph.
104
   * Notifies observers when the caret changes paragraph.
105105
   *
106
   * @param listener Receives paragraph change events.
106
   * @param listener Receives change event.
107107
   */
108108
  public void addCaretParagraphListener(
109109
      final ChangeListener<? super Integer> listener ) {
110110
    getEditor().currentParagraphProperty().addListener( listener );
111
  }
112
113
  /**
114
   * Notifies observers when the caret changes position.
115
   *
116
   * @param listener Receives change event.
117
   */
118
  public void addCaretPositionListener(
119
      final ChangeListener<? super Integer> listener ) {
120
    getEditor().caretPositionProperty().addListener( listener );
111121
  }
112122
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
3333
import com.scrivenvar.definition.FindMode;
3434
import com.scrivenvar.definition.VariableTreeItem;
35
import javafx.event.Event;
3635
import javafx.scene.control.TreeItem;
3736
import javafx.scene.input.KeyEvent;
3837
import org.fxmisc.richtext.StyledTextArea;
39
import org.fxmisc.wellbehaved.event.EventPattern;
4038
4139
import java.nio.file.Path;
4240
import java.text.BreakIterator;
43
import java.util.function.Consumer;
4441
4542
import static com.scrivenvar.definition.FindMode.*;
...
6360
   * Initiates double-click events.
6461
   */
65
  private DefinitionPane mDefinitionPane;
62
  private final DefinitionPane mDefinitionPane;
6663
6764
  /**
6865
   * Initializes the variable name injector against the given pane.
6966
   *
70
   * @param tab  The tab to inject variable names into.
7167
   * @param pane The definition panel to listen to for double-click events.
7268
   */
73
  public VariableNameInjector(
74
      final FileEditorTab tab, final DefinitionPane pane ) {
75
    setFileEditorTab( tab );
76
    setDefinitionPane( pane );
77
    initKeyboardEventListeners();
69
  public VariableNameInjector( final DefinitionPane pane ) {
70
    mDefinitionPane = pane;
7871
  }
7972
8073
  /**
81
   * Trap control+space and the @ key.
74
   * Trap Control+Space.
8275
   *
83
   * @param tab The file editor that sends keyboard events for variable name
84
   *            injection.
76
   * @param tab Editor where variable names get injected.
8577
   */
86
  public void initKeyboardEventListeners( final FileEditorTab tab ) {
87
    setFileEditorTab( tab );
88
    initKeyboardEventListeners();
78
  public void addListener( final FileEditorTab tab ) {
79
    assert tab != null;
80
    mTab = tab;
81
82
    tab.getEditorPane().addKeyboardListener(
83
        keyPressed( SPACE, CONTROL_DOWN ),
84
        this::autoinsert
85
    );
8986
  }
9087
9188
  /**
9289
   * Inserts the variable
9390
   */
9491
  public void injectSelectedItem() {
95
    final TreeItem<String> item = getDefinitionPane().getSelectedItem();
92
    final var pane = getDefinitionPane();
93
    final TreeItem<String> item = pane.getSelectedItem();
9694
9795
    if( item.isLeaf() ) {
98
      // This avoids a direct typecast.
99
      final VariableTreeItem<String> leaf = getDefinitionPane().findLeaf(
100
          item.getValue(), FindMode.EXACT );
101
      final StyledTextArea<?, ?> editor = getEditor();
96
      final var leaf = pane.findLeaf( item.getValue(), FindMode.EXACT );
97
      final var editor = getEditor();
10298
10399
      editor.insertText( editor.getCaretPosition(), decorate( leaf ) );
104100
    }
105
  }
106
107
  /**
108
   * Traps Control+SPACE to auto-insert definition key names.
109
   */
110
  private void initKeyboardEventListeners() {
111
    addKeyboardListener(
112
        keyPressed( SPACE, CONTROL_DOWN ),
113
        this::autoinsert
114
    );
115101
  }
116102
...
280266
  private EditorPane getEditorPane() {
281267
    return getFileEditorTab().getEditorPane();
282
  }
283
284
  /**
285
   * Delegates to the file editor pane, and, ultimately, to its text area.
286
   */
287
  private <T extends Event, U extends T> void addKeyboardListener(
288
      final EventPattern<? super T, ? extends U> event,
289
      final Consumer<? super U> consumer ) {
290
    getEditorPane().addKeyboardListener( event, consumer );
291268
  }
292269
293270
  private StyledTextArea<?, ?> getEditor() {
294271
    return getEditorPane().getEditor();
295272
  }
296273
297274
  public FileEditorTab getFileEditorTab() {
298275
    return mTab;
299
  }
300
301
  public void setFileEditorTab( final FileEditorTab tab ) {
302
    mTab = tab;
303276
  }
304277
305278
  private DefinitionPane getDefinitionPane() {
306279
    return mDefinitionPane;
307
  }
308
309
  private void setDefinitionPane( final DefinitionPane definitionPane ) {
310
    mDefinitionPane = definitionPane;
311280
  }
312281
}
M src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
3737
import com.vladsch.flexmark.util.ast.Node;
3838
import com.vladsch.flexmark.util.html.MutableAttributes;
39
import javafx.beans.value.ChangeListener;
4039
import javafx.scene.control.Dialog;
4140
import javafx.scene.control.IndexRange;
...
8382
  public void insertImage() {
8483
    insertObject( createImageDialog() );
85
  }
86
87
  public void addCaretListener( final ChangeListener<Integer> listener ) {
88
    getEditor().caretPositionProperty().addListener( listener );
8984
  }
9085
9186
  /**
92
   * Aligns the editor's paragraph number with the paragraph number generated
93
   * by HTML. Ultimately this solution is flawed because there isn't
87
   * Returns the editor's paragraph number that will be close to its HTML
88
   * paragraph ID. Ultimately this solution is flawed because there isn't
9489
   * a straightforward correlation between the document being edited and
9590
   * what is rendered. XML documents transformed through stylesheets have
...
107102
   * </p>
108103
   *
109
   * @return A unique identifier that correlates to the preview pane.
104
   * @return A unique identifier that correlates to an equivalent paragraph
105
   * number once the Markdown is rendered into HTML.
110106
   */
111
  public String getCurrentParagraphId() {
107
  public int approximateParagraphId( final int paraIndex ) {
112108
    final StyleClassedTextArea editor = getEditor();
113
    final int paraIndex = editor.getCurrentParagraph();
114
    int i = 0, paragraphs = 0;
109
    int i = 0, paragraph = 0;
115110
116111
    while( i < paraIndex ) {
117
      final String text = editor.getParagraph( i++ ).getText().trim();
112
      // Reduce numerously nested blockquotes to blanks for isBlank call.
113
      final String text = editor.getParagraph( i++ )
114
                                .getText()
115
                                .replace( '>', ' ' );
118116
119
      paragraphs += text.isEmpty() || text.equals( ">" ) ? 0 : 1;
117
      paragraph += text.isBlank() ? 0 : 1;
120118
    }
121119
122
    return "para-" + paragraphs;
120
    return paragraph;
121
  }
122
123
  /**
124
   * Gets the index of the paragraph where the caret is positioned.
125
   *
126
   * @return The paragraph number for the caret.
127
   */
128
  public int getCurrentParagraphIndex() {
129
    return getEditor().getCurrentParagraph();
123130
  }
124131
M src/main/java/com/scrivenvar/preferences/FilePreferences.java
6868
  @Override
6969
  protected void putSpi( final String key, final String value ) {
70
    mRoot.put( key, value );
70
    synchronized( mMutex ) {
71
      mRoot.put( key, value );
72
    }
7173
7274
    try {
...
7981
  @Override
8082
  protected String getSpi( final String key ) {
81
    return mRoot.get( key );
83
    synchronized( mMutex ) {
84
      return mRoot.get( key );
85
    }
8286
  }
8387
8488
  @Override
8589
  protected void removeSpi( final String key ) {
86
    mRoot.remove( key );
90
    synchronized( mMutex ) {
91
      mRoot.remove( key );
92
    }
8793
8894
    try {
...
101107
  @Override
102108
  protected String[] keysSpi() {
103
    return mRoot.keySet().toArray( new String[ 0 ] );
109
    synchronized( mMutex ) {
110
      return mRoot.keySet().toArray( new String[ 0 ] );
111
    }
104112
  }
105113
...
136144
      final Properties p = new Properties();
137145
138
      try {
139
        p.load( new FileInputStream( file ) );
146
      try( final var inputStream = new FileInputStream( file ) ) {
147
        p.load( inputStream );
140148
141149
        final String path = getPath();
...
177185
178186
        if( file.exists() ) {
179
          p.load( new FileInputStream( file ) );
187
          try( final var fis = new FileInputStream( file ) ) {
188
            p.load( fis );
189
          }
180190
181191
          final List<String> toRemove = new ArrayList<>();
...
209219
        }
210220
211
        p.store( new FileOutputStream( file ), "FilePreferences" );
221
        try( final var fos = new FileOutputStream( file ) ) {
222
          p.store( fos, "FilePreferences" );
223
        }
212224
      } catch( final Exception ex ) {
213225
        error( new BackingStoreException( ex ) );
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
11
/*
2
 * Copyright 2016 Karl Tauber and White Magic Software, Ltd.
2
 * Copyright 2020 Karl Tauber and White Magic Software, Ltd.
33
 *
44
 * All rights reserved.
...
2828
package com.scrivenvar.preview;
2929
30
import javafx.beans.property.BooleanProperty;
31
import javafx.beans.property.SimpleBooleanProperty;
32
import javafx.beans.value.ChangeListener;
33
import javafx.beans.value.ObservableValue;
3034
import javafx.embed.swing.SwingNode;
3135
import javafx.scene.Node;
3236
import javafx.scene.layout.Pane;
3337
import org.jsoup.Jsoup;
3438
import org.jsoup.helper.W3CDom;
3539
import org.jsoup.nodes.Document;
40
import org.xhtmlrenderer.event.DocumentListener;
3641
import org.xhtmlrenderer.layout.SharedContext;
3742
import org.xhtmlrenderer.render.Box;
...
4449
import java.nio.file.Path;
4550
51
import static com.scrivenvar.Constants.PARAGRAPH_ID_PREFIX;
4652
import static com.scrivenvar.Constants.STYLESHEET_PREVIEW;
4753
...
5864
    @Override
5965
    public void resetScrollPosition() {
66
    }
67
  }
68
69
  /**
70
   * Prevent scroll attempts until after the document has loaded.
71
   */
72
  private static final class DocumentEventHandler implements DocumentListener {
73
    private final BooleanProperty mReadyProperty = new SimpleBooleanProperty();
74
75
    public BooleanProperty readyProperty() {
76
      return mReadyProperty;
77
    }
78
79
    @Override
80
    public void documentStarted() {
81
      mReadyProperty.setValue( Boolean.FALSE );
82
    }
83
84
    @Override
85
    public void documentLoaded() {
86
      mReadyProperty.setValue( Boolean.TRUE );
87
    }
88
89
    @Override
90
    public void onLayoutException( final Throwable t ) {
91
    }
92
93
    @Override
94
    public void onRenderException( final Throwable t ) {
6095
    }
6196
  }
...
79114
  private final SwingNode mSwingNode = new SwingNode();
80115
  private final JScrollPane mScrollPane = new JScrollPane( mRenderer );
116
  private final DocumentEventHandler mDocumentHandler =
117
      new DocumentEventHandler();
81118
82119
  private Path mPath;
...
99136
    mHtml.append( HTML_HEADER );
100137
    mHtmlPrefixLength = mHtml.length();
138
139
    mRenderer.addDocumentListener( mDocumentHandler );
101140
  }
102141
...
120159
   * @param id The unique anchor link identifier.
121160
   */
122
  public void scrollTo( final String id ) {
123
    assert id != null;
124
    final Box box = getSharedContext().getBoxById( id );
161
  public void tryScrollTo( final int id ) {
162
    final ChangeListener<Boolean> listener = new ChangeListener<>() {
163
      @Override
164
      public void changed(
165
          final ObservableValue<? extends Boolean> observable,
166
          final Boolean oldValue,
167
          final Boolean newValue ) {
168
        if( newValue ) {
169
          scrollTo( id );
125170
126
    if( box != null ) {
127
      mRenderer.scrollTo( createPoint( box ) );
171
          mDocumentHandler.readyProperty().removeListener( this );
172
        }
173
      }
174
    };
175
176
    mDocumentHandler.readyProperty().addListener( listener );
177
  }
178
179
  /**
180
   * Scrolls to the closest element matching the given identifier without
181
   * waiting for the document to be ready. Be sure the document is ready
182
   * before calling this method.
183
   *
184
   * @param id Paragraph index.
185
   */
186
  public void scrollTo( final int id ) {
187
    if( id < 2 ) {
188
      scrollToTop();
189
    }
190
    else {
191
      Box box = findPrevBox( id );
192
      box = box == null ? findNextBox( id + 1 ) : box;
193
194
      if( box == null ) {
195
        srollToBottom();
196
      }
197
      else {
198
        scrollTo( box );
199
      }
200
    }
201
  }
202
203
  private Box findPrevBox( final int id ) {
204
    int prevId = id;
205
    Box box = null;
206
207
    while( prevId > 0 && (box = getBoxById( PARAGRAPH_ID_PREFIX + prevId )) == null ) {
208
      prevId--;
209
    }
210
211
    return box;
212
  }
213
214
  private Box findNextBox( final int id ) {
215
    int nextId = id;
216
    Box box = null;
217
218
    while( nextId - id < 5 &&
219
        (box = getBoxById( PARAGRAPH_ID_PREFIX + nextId )) == null ) {
220
      nextId++;
128221
    }
222
223
    return box;
224
  }
225
226
  private void scrollTo( final Point point ) {
227
    mRenderer.scrollTo( point );
228
  }
229
230
  private void scrollTo( final Box box ) {
231
    scrollTo( createPoint( box ) );
232
  }
233
234
  private void scrollToY( final int y ) {
235
    scrollTo( new Point( 0, y ) );
236
  }
237
238
  private void scrollToTop() {
239
    scrollToY( 0 );
240
  }
241
242
  private void srollToBottom() {
243
    scrollToY( mRenderer.getHeight() );
244
  }
245
246
  private Box getBoxById( final String id ) {
247
    return getSharedContext().getBoxById( id );
129248
  }
130249
...
137256
                .append( HTML_FOOTER )
138257
                .toString();
139
  }
140
141
  /**
142
   * Clears out the HTML content from the preview.
143
   */
144
  public void clear() {
145
    update( "" );
146258
  }
147259
...
180292
    // area within the view port. Otherwise the view port will have jumped too
181293
    // high up and the whatever gets typed won't be visible.
182
    int y = Math.abs(
183
        box.getAbsY() - (mScrollPane.getVerticalScrollBar().getHeight() / 2)
184
    );
294
    int y = Math.max(
295
        box.getAbsY() - (mScrollPane.getVerticalScrollBar().getHeight() / 2),
296
        0 );
185297
186298
    if( !box.getStyle().isInline() ) {
M src/main/java/com/scrivenvar/processors/RVariableProcessor.java
6464
    final Map<String, String> rMap = new HashMap<>( map.size() );
6565
66
    for( final String key : map.keySet() ) {
66
    for( final Map.Entry<String, String> entry : map.entrySet() ) {
67
      final var key = entry.getKey();
6768
      rMap.put( toRKey( key ), toRValue( map.get( key ) ) );
6869
    }
M src/main/java/com/scrivenvar/processors/markdown/BlockExtension.java
1414
import org.jetbrains.annotations.NotNull;
1515
16
import static com.scrivenvar.Constants.PARAGRAPH_ID_PREFIX;
17
1618
/**
1719
 * Responsible for giving most block-level elements a unique identifier
...
4648
      // does not count as a blank line. Resolving this issue is tricky.
4749
      if( node instanceof Block && !(node instanceof BlockQuote) ) {
48
        attributes.addValue( "id", "para-" + mCount++ );
50
        attributes.addValue( "id", PARAGRAPH_ID_PREFIX + mCount++ );
4951
      }
5052
    }
M src/main/resources/com/scrivenvar/editor/markdown.css
11
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
2
 * Copyright 2020 Karl Tauber and White Magic Software, Ltd.
3
 *
34
 * All rights reserved.
45
 *
...
2829
.markdown-editor {
2930
  -fx-font-size: 14px;
30
}
31
32
/*---- headers ----*/
33
34
.markdown-editor .h1 { -fx-font-size: 2.25em; }
35
.markdown-editor .h2 { -fx-font-size: 1.75em; }
36
.markdown-editor .h3 { -fx-font-size: 1.5em; }
37
.markdown-editor .h4 { -fx-font-size: 1.25em; }
38
.markdown-editor .h5 { -fx-font-size: 1.1em; }
39
.markdown-editor .h6 { -fx-font-size: 1em; }
40
41
.markdown-editor .h1,
42
.markdown-editor .h2,
43
.markdown-editor .h3,
44
.markdown-editor .h4,
45
.markdown-editor .h5,
46
.markdown-editor .h6 {
47
  -fx-font-weight: bold;
48
  -fx-fill: derive(crimson, -20%);
49
}
50
51
52
/*---- inlines ----*/
53
54
.markdown-editor .strong {
55
  -fx-font-weight: bold;
56
}
57
58
.markdown-editor .em {
59
  -fx-font-style: italic;
60
}
61
62
.markdown-editor .del {
63
  -fx-strikethrough: true;
64
}
65
66
.markdown-editor .a {
67
  -fx-fill: #4183C4 !important;
68
}
69
70
.markdown-editor .img {
71
  -fx-fill: #4183C4 !important;
72
}
73
74
.markdown-editor .code {
75
  -fx-font-family: monospace;
76
  -fx-fill: #090 !important;
77
}
78
79
80
/*---- blocks ----*/
81
82
.markdown-editor .pre {
83
  -fx-font-family: monospace;
84
  -fx-fill: #060 !important;
85
}
86
87
.markdown-editor .blockquote {
88
  -fx-fill: #777;
89
}
90
91
92
/*---- lists ----*/
93
94
.markdown-editor .ul {
95
}
96
97
.markdown-editor .ol {
98
}
99
100
.markdown-editor .li {
101
  -fx-fill: #444;
102
}
103
104
.markdown-editor .dl {
105
}
106
107
.markdown-editor .dt {
108
  -fx-font-weight: bold;
109
  -fx-font-style: italic;
110
}
111
112
.markdown-editor .dd {
113
  -fx-fill: #444;
114
}
115
116
117
/*---- table ----*/
118
119
.markdown-editor .table {
120
  -fx-font-family: monospace;
121
}
122
123
.markdown-editor .thead {
124
}
125
126
.markdown-editor .tbody {
127
}
128
129
.markdown-editor .caption {
130
}
131
132
.markdown-editor .th {
133
  -fx-font-weight: bold;
134
}
135
136
.markdown-editor .tr {
13731
}
13832
139
.markdown-editor .td {
33
/* Subtly highlight the current paragraph. */
34
.markdown-editor .paragraph-box:has-caret {
35
  -fx-background-color: #fcfeff;
14036
}
141
142
143
/*---- misc ----*/
14437
145
.markdown-editor .html {
146
  -fx-font-family: monospace;
147
  -fx-fill: derive(crimson, -50%);
148
}
149
.markdown-editor .monospace {
150
  -fx-font-family: monospace;
38
/* Light colour for selection highlight. */
39
.markdown-editor .selection {
40
  -fx-fill: #a6d2ff;
15141
}
15242