Dave Jarvis' Repositories

M build.gradle
1
version = '1.0.7'
1
version = '1.0.8'
22
33
apply plugin: 'java'
...
2020
2121
dependencies {
22
  compile 'org.controlsfx:controlsfx:8.40.12'
2223
  compile 'org.fxmisc.richtext:richtextfx:0.7-M2'
2324
  compile 'com.miglayout:miglayout-javafx:5.0'
M src/main/java/com/scrivenvar/AbstractFileFactory.java
5353
   * @return The file type that corresponds to the given path.
5454
   */
55
    protected FileType lookup( final Path path, final String prefix ) {
55
  protected FileType lookup( final Path path, final String prefix ) {
5656
    final Settings properties = getSettings();
5757
    final Iterator<String> keys = properties.getKeys( prefix );
...
109109
    return this.settings;
110110
  }
111
112111
}
113112
M src/main/java/com/scrivenvar/FileEditorTab.java
2929
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
3030
import com.scrivenvar.service.events.Notification;
31
import com.scrivenvar.service.events.NotifyService;
32
import java.nio.charset.Charset;
33
import java.nio.file.Files;
34
import java.nio.file.Path;
35
import static java.util.Locale.ENGLISH;
36
import java.util.function.Consumer;
37
import javafx.application.Platform;
38
import javafx.beans.binding.Bindings;
39
import javafx.beans.property.BooleanProperty;
40
import javafx.beans.property.ReadOnlyBooleanProperty;
41
import javafx.beans.property.ReadOnlyBooleanWrapper;
42
import javafx.beans.property.SimpleBooleanProperty;
43
import javafx.beans.value.ChangeListener;
44
import javafx.beans.value.ObservableValue;
45
import javafx.event.Event;
46
import javafx.scene.Node;
47
import javafx.scene.control.Tab;
48
import javafx.scene.control.Tooltip;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.text.Text;
51
import org.fxmisc.richtext.StyleClassedTextArea;
52
import org.fxmisc.undo.UndoManager;
53
import org.fxmisc.wellbehaved.event.EventPattern;
54
import org.fxmisc.wellbehaved.event.InputMap;
55
import org.mozilla.universalchardet.UniversalDetector;
56
57
/**
58
 * Editor for a single file.
59
 *
60
 * @author Karl Tauber and White Magic Software, Ltd.
61
 */
62
public final class FileEditorTab extends Tab {
63
64
  private final NotifyService alertService = Services.load( NotifyService.class );
65
  private EditorPane editorPane;
66
67
  /**
68
   * Character encoding used by the file (or default encoding if none found).
69
   */
70
  private Charset encoding;
71
72
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
73
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
74
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
75
76
  // Might be simpler to revert this back to a property and have the main
77
  // window listen for changes to it...
78
  private Path path;
79
80
  FileEditorTab( final Path path ) {
81
    setPath( path );
82
83
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
84
    updateTab();
85
86
    setOnSelectionChanged( e -> {
87
      if( isSelected() ) {
88
        Platform.runLater( () -> activated() );
89
      }
90
    } );
91
  }
92
93
  private void updateTab() {
94
    setText( getTabTitle() );
95
    setGraphic( getModifiedMark() );
96
    setTooltip( getTabTooltip() );
97
  }
98
99
  /**
100
   * Returns the base filename (without the directory names).
101
   *
102
   * @return The untitled text if the path hasn't been set.
103
   */
104
  private String getTabTitle() {
105
    final Path filePath = getPath();
106
107
    return (filePath == null)
108
      ? Messages.get( "FileEditor.untitled" )
109
      : filePath.getFileName().toString();
110
  }
111
112
  /**
113
   * Returns the full filename represented by the path.
114
   *
115
   * @return The untitled text if the path hasn't been set.
116
   */
117
  private Tooltip getTabTooltip() {
118
    final Path filePath = getPath();
119
    return new Tooltip( filePath == null ? "" : filePath.toString() );
120
  }
121
122
  /**
123
   * Returns a marker to indicate whether the file has been modified.
124
   *
125
   * @return "*" when the file has changed; otherwise null.
126
   */
127
  private Text getModifiedMark() {
128
    return isModified() ? new Text( "*" ) : null;
129
  }
130
131
  /**
132
   * Called when the user switches tab.
133
   */
134
  private void activated() {
135
    // Tab is closed or no longer active.
136
    if( getTabPane() == null || !isSelected() ) {
137
      return;
138
    }
139
140
    // Switch to the tab without loading if the contents are already in memory.
141
    if( getContent() != null ) {
142
      getEditorPane().requestFocus();
143
      return;
144
    }
145
146
    // Load the text and update the preview before the undo manager.
147
    load();
148
149
    // Track undo requests -- can only be called *after* load.
150
    initUndoManager();
151
    initLayout();
152
    initFocus();
153
  }
154
155
  private void initLayout() {
156
    setContent( getScrollPane() );
157
  }
158
159
  private Node getScrollPane() {
160
    return getEditorPane().getScrollPane();
161
  }
162
163
  private void initFocus() {
164
    getEditorPane().requestFocus();
165
  }
166
167
  private void initUndoManager() {
168
    final UndoManager undoManager = getUndoManager();
169
170
    // Clear undo history after first load.
171
    undoManager.forgetHistory();
172
173
    // Bind the editor undo manager to the properties.
174
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
175
    canUndo.bind( undoManager.undoAvailableProperty() );
176
    canRedo.bind( undoManager.redoAvailableProperty() );
177
  }
178
179
  /**
180
   * Returns the index into the text where the caret blinks happily away.
181
   *
182
   * @return A number from 0 to the editor's document text length.
183
   */
184
  public int getCaretPosition() {
185
    return getEditor().getCaretPosition();
186
  }
187
188
  /**
189
   * Allows observers to synchronize caret position changes.
190
   *
191
   * @return An observable caret property value.
192
   */
193
  public final ObservableValue<Integer> caretPositionProperty() {
194
    return getEditor().caretPositionProperty();
195
  }
196
197
  /**
198
   * Returns the text area associated with this tab.
199
   *
200
   * @return A text editor.
201
   */
202
  private StyleClassedTextArea getEditor() {
203
    return getEditorPane().getEditor();
204
  }
205
206
  /**
207
   * Returns true if the given path exactly matches this tab's path.
208
   *
209
   * @param check The path to compare against.
210
   *
211
   * @return true The paths are the same.
212
   */
213
  public boolean isPath( final Path check ) {
214
    final Path filePath = getPath();
215
216
    return filePath == null ? false : filePath.equals( check );
217
  }
218
219
  /**
220
   * Reads the entire file contents from the path associated with this tab.
221
   */
222
  private void load() {
223
    final Path filePath = getPath();
224
225
    if( filePath != null ) {
226
      try {
227
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
228
      } catch( Exception ex ) {
229
        alert(
230
          "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
231
        );
232
      }
233
    }
234
  }
235
236
  /**
237
   * Saves the entire file contents from the path associated with this tab.
238
   *
239
   * @return true The file has been saved.
240
   */
241
  public boolean save() {
242
    try {
243
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
244
      getEditorPane().getUndoManager().mark();
245
      return true;
246
    } catch( Exception ex ) {
247
      return alert(
248
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
249
      );
250
    }
251
  }
252
253
  /**
254
   * Creates an alert dialog and waits for it to close.
255
   *
256
   * @param titleKey Resource bundle key for the alert dialog title.
257
   * @param messageKey Resource bundle key for the alert dialog message.
258
   * @param e The unexpected happening.
259
   *
260
   * @return false
261
   */
262
  private boolean alert(
263
    final String titleKey, final String messageKey, final Exception e ) {
264
    final NotifyService service = getAlertService();
265
    final Path filePath = getPath();
266
267
    final Notification message = service.createNotification(
268
      Messages.get( titleKey ),
269
      Messages.get( messageKey ),
270
      filePath == null ? "" : filePath,
271
      e.getMessage()
272
    );
273
274
    // TODO: Put this into a status bar or status area
275
    System.out.println( e );
276
277
//    service.createError( message ).showAndWait();
278
    return false;
279
  }
280
281
  /**
282
   * Returns a best guess at the file encoding. If the encoding could not be
283
   * detected, this will return the default charset for the JVM.
284
   *
285
   * @param bytes The bytes to perform character encoding detection.
286
   *
287
   * @return The character encoding.
288
   */
289
  private Charset detectEncoding( final byte[] bytes ) {
290
    final UniversalDetector detector = new UniversalDetector( null );
291
    detector.handleData( bytes, 0, bytes.length );
292
    detector.dataEnd();
293
294
    final String charset = detector.getDetectedCharset();
295
    final Charset charEncoding = charset == null
296
      ? Charset.defaultCharset()
297
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
298
299
    detector.reset();
300
301
    return charEncoding;
302
  }
303
304
  /**
305
   * Converts the given string to an array of bytes using the encoding that was
306
   * originally detected (if any) and associated with this file.
307
   *
308
   * @param text The text to convert into the original file encoding.
309
   *
310
   * @return A series of bytes ready for writing to a file.
311
   */
312
  private byte[] asBytes( final String text ) {
313
    return text.getBytes( getEncoding() );
314
  }
315
316
  /**
317
   * Converts the given bytes into a Java String. This will call setEncoding
318
   * with the encoding detected by the CharsetDetector.
319
   *
320
   * @param text The text of unknown character encoding.
321
   *
322
   * @return The text, in its auto-detected encoding, as a String.
323
   */
324
  private String asString( final byte[] text ) {
325
    setEncoding( detectEncoding( text ) );
326
    return new String( text, getEncoding() );
327
  }
328
329
  public Path getPath() {
330
    return this.path;
331
  }
332
333
  public void setPath( final Path path ) {
334
    this.path = path;
335
  }
336
337
  /**
338
   * Answers whether this tab has an initialized path reference.
339
   *
340
   * @return false This tab has no path.
341
   */
342
  public boolean isFileOpen() {
343
    return this.path != null;
344
  }
345
346
  public boolean isModified() {
347
    return this.modified.get();
348
  }
349
350
  ReadOnlyBooleanProperty modifiedProperty() {
351
    return this.modified.getReadOnlyProperty();
352
  }
353
354
  BooleanProperty canUndoProperty() {
355
    return this.canUndo;
356
  }
357
358
  BooleanProperty canRedoProperty() {
359
    return this.canRedo;
360
  }
361
362
  private UndoManager getUndoManager() {
363
    return getEditorPane().getUndoManager();
364
  }
365
366
  /**
367
   * Forwards the request to the editor pane.
368
   *
369
   * @param <T> The type of event listener to add.
370
   * @param <U> The type of consumer to add.
371
   * @param event The event that should trigger updates to the listener.
372
   * @param consumer The listener to receive update events.
373
   */
374
  public <T extends Event, U extends T> void addEventListener(
375
    final EventPattern<? super T, ? extends U> event,
376
    final Consumer<? super U> consumer ) {
377
    getEditorPane().addEventListener( event, consumer );
378
  }
379
380
  /**
381
   * Forwards to the editor pane's listeners for keyboard events.
382
   *
383
   * @param map The new input map to replace the existing keyboard listener.
384
   */
385
  public void addEventListener( final InputMap<InputEvent> map ) {
386
    getEditorPane().addEventListener( map );
387
  }
388
389
  /**
390
   * Forwards to the editor pane's listeners for keyboard events.
391
   *
392
   * @param map The existing input map to remove from the keyboard listeners.
393
   */
394
  public void removeEventListener( final InputMap<InputEvent> map ) {
395
    getEditorPane().removeEventListener( map );
396
  }
397
398
  /**
399
   * Forwards to the editor pane's listeners for text change events.
400
   *
401
   * @param listener The listener to notify when the text changes.
402
   */
403
  public void addTextChangeListener( final ChangeListener<String> listener ) {
404
    getEditorPane().addTextChangeListener( listener );
405
  }
406
407
  /**
408
   * Forwards to the editor pane's listeners for caret paragraph change events.
409
   *
410
   * @param listener The listener to notify when the caret changes paragraphs.
411
   */
412
  public void addCaretParagraphListener( final ChangeListener<Integer> listener ) {
413
    getEditorPane().addCaretParagraphListener( listener );
414
  }
415
416
  /**
417
   * Forwards the request to the editor pane.
418
   *
419
   * @return The text to process.
420
   */
421
  public String getEditorText() {
422
    return getEditorPane().getText();
423
  }
424
425
  /**
426
   * Returns the editor pane, or creates one if it doesn't yet exist.
427
   *
428
   * @return The editor pane, never null.
429
   */
430
  public EditorPane getEditorPane() {
431
    if( this.editorPane == null ) {
432
      this.editorPane = new MarkdownEditorPane();
433
    }
434
435
    return this.editorPane;
436
  }
437
438
  private NotifyService getAlertService() {
31
import com.scrivenvar.service.events.Notifier;
32
import java.nio.charset.Charset;
33
import java.nio.file.Files;
34
import java.nio.file.Path;
35
import static java.util.Locale.ENGLISH;
36
import java.util.function.Consumer;
37
import javafx.application.Platform;
38
import javafx.beans.binding.Bindings;
39
import javafx.beans.property.BooleanProperty;
40
import javafx.beans.property.ReadOnlyBooleanProperty;
41
import javafx.beans.property.ReadOnlyBooleanWrapper;
42
import javafx.beans.property.SimpleBooleanProperty;
43
import javafx.beans.value.ChangeListener;
44
import javafx.beans.value.ObservableValue;
45
import javafx.event.Event;
46
import javafx.scene.Node;
47
import javafx.scene.control.Tab;
48
import javafx.scene.control.Tooltip;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.text.Text;
51
import javafx.stage.Window;
52
import org.fxmisc.richtext.StyleClassedTextArea;
53
import org.fxmisc.undo.UndoManager;
54
import org.fxmisc.wellbehaved.event.EventPattern;
55
import org.fxmisc.wellbehaved.event.InputMap;
56
import org.mozilla.universalchardet.UniversalDetector;
57
58
/**
59
 * Editor for a single file.
60
 *
61
 * @author Karl Tauber and White Magic Software, Ltd.
62
 */
63
public final class FileEditorTab extends Tab {
64
65
  private final Notifier alertService = Services.load(Notifier.class );
66
  private EditorPane editorPane;
67
68
  /**
69
   * Character encoding used by the file (or default encoding if none found).
70
   */
71
  private Charset encoding;
72
73
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
74
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
75
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
76
77
  // Might be simpler to revert this back to a property and have the main
78
  // window listen for changes to it...
79
  private Path path;
80
81
  FileEditorTab( final Path path ) {
82
    setPath( path );
83
84
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
85
    updateTab();
86
87
    setOnSelectionChanged( e -> {
88
      if( isSelected() ) {
89
        Platform.runLater( () -> activated() );
90
      }
91
    } );
92
  }
93
94
  private void updateTab() {
95
    setText( getTabTitle() );
96
    setGraphic( getModifiedMark() );
97
    setTooltip( getTabTooltip() );
98
  }
99
100
  /**
101
   * Returns the base filename (without the directory names).
102
   *
103
   * @return The untitled text if the path hasn't been set.
104
   */
105
  private String getTabTitle() {
106
    final Path filePath = getPath();
107
108
    return (filePath == null)
109
      ? Messages.get( "FileEditor.untitled" )
110
      : filePath.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
171
    // Clear undo history after first load.
172
    undoManager.forgetHistory();
173
174
    // Bind the editor undo manager to the properties.
175
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
176
    canUndo.bind( undoManager.undoAvailableProperty() );
177
    canRedo.bind( undoManager.redoAvailableProperty() );
178
  }
179
180
  /**
181
   * Returns the index into the text where the caret blinks happily away.
182
   *
183
   * @return A number from 0 to the editor's document text length.
184
   */
185
  public int getCaretPosition() {
186
    return getEditor().getCaretPosition();
187
  }
188
189
  /**
190
   * Allows observers to synchronize caret position changes.
191
   *
192
   * @return An observable caret property value.
193
   */
194
  public final ObservableValue<Integer> caretPositionProperty() {
195
    return getEditor().caretPositionProperty();
196
  }
197
198
  /**
199
   * Returns the text area associated with this tab.
200
   *
201
   * @return A text editor.
202
   */
203
  private StyleClassedTextArea getEditor() {
204
    return getEditorPane().getEditor();
205
  }
206
207
  /**
208
   * Returns true if the given path exactly matches this tab's path.
209
   *
210
   * @param check The path to compare against.
211
   *
212
   * @return true The paths are the same.
213
   */
214
  public boolean isPath( final Path check ) {
215
    final Path filePath = getPath();
216
217
    return filePath == null ? false : filePath.equals( check );
218
  }
219
220
  /**
221
   * Reads the entire file contents from the path associated with this tab.
222
   */
223
  private void load() {
224
    final Path filePath = getPath();
225
226
    if( filePath != null ) {
227
      try {
228
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
229
      } catch( final Exception ex ) {
230
        getNotifyService().notify( ex );
231
      }
232
    }
233
  }
234
235
  /**
236
   * Saves the entire file contents from the path associated with this tab.
237
   *
238
   * @return true The file has been saved.
239
   */
240
  public boolean save() {
241
    try {
242
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
243
      getEditorPane().getUndoManager().mark();
244
      return true;
245
    } catch( final Exception ex ) {
246
      return alert(
247
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
248
      );
249
    }
250
  }
251
252
  /**
253
   * Creates an alert dialog and waits for it to close.
254
   *
255
   * @param titleKey Resource bundle key for the alert dialog title.
256
   * @param messageKey Resource bundle key for the alert dialog message.
257
   * @param e The unexpected happening.
258
   *
259
   * @return false
260
   */
261
  private boolean alert(
262
    final String titleKey, final String messageKey, final Exception e ) {
263
    final Notifier service = getNotifyService();
264
    final Path filePath = getPath();
265
266
    final Notification message = service.createNotification(
267
      Messages.get( titleKey ),
268
      Messages.get( messageKey ),
269
      filePath == null ? "" : filePath,
270
      e.getMessage()
271
    );
272
273
    service.createError( getWindow(), message ).showAndWait();
274
    return false;
275
  }
276
277
  private Window getWindow() {
278
    return getEditorPane().getScene().getWindow();
279
  }
280
281
  /**
282
   * Returns a best guess at the file encoding. If the encoding could not be
283
   * detected, this will return the default charset for the JVM.
284
   *
285
   * @param bytes The bytes to perform character encoding detection.
286
   *
287
   * @return The character encoding.
288
   */
289
  private Charset detectEncoding( final byte[] bytes ) {
290
    final UniversalDetector detector = new UniversalDetector( null );
291
    detector.handleData( bytes, 0, bytes.length );
292
    detector.dataEnd();
293
294
    final String charset = detector.getDetectedCharset();
295
    final Charset charEncoding = charset == null
296
      ? Charset.defaultCharset()
297
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
298
299
    detector.reset();
300
301
    return charEncoding;
302
  }
303
304
  /**
305
   * Converts the given string to an array of bytes using the encoding that was
306
   * originally detected (if any) and associated with this file.
307
   *
308
   * @param text The text to convert into the original file encoding.
309
   *
310
   * @return A series of bytes ready for writing to a file.
311
   */
312
  private byte[] asBytes( final String text ) {
313
    return text.getBytes( getEncoding() );
314
  }
315
316
  /**
317
   * Converts the given bytes into a Java String. This will call setEncoding
318
   * with the encoding detected by the CharsetDetector.
319
   *
320
   * @param text The text of unknown character encoding.
321
   *
322
   * @return The text, in its auto-detected encoding, as a String.
323
   */
324
  private String asString( final byte[] text ) {
325
    setEncoding( detectEncoding( text ) );
326
    return new String( text, getEncoding() );
327
  }
328
329
  public Path getPath() {
330
    return this.path;
331
  }
332
333
  public void setPath( final Path path ) {
334
    this.path = path;
335
  }
336
337
  /**
338
   * Answers whether this tab has an initialized path reference.
339
   *
340
   * @return false This tab has no path.
341
   */
342
  public boolean isFileOpen() {
343
    return this.path != null;
344
  }
345
346
  public boolean isModified() {
347
    return this.modified.get();
348
  }
349
350
  ReadOnlyBooleanProperty modifiedProperty() {
351
    return this.modified.getReadOnlyProperty();
352
  }
353
354
  BooleanProperty canUndoProperty() {
355
    return this.canUndo;
356
  }
357
358
  BooleanProperty canRedoProperty() {
359
    return this.canRedo;
360
  }
361
362
  private UndoManager getUndoManager() {
363
    return getEditorPane().getUndoManager();
364
  }
365
366
  /**
367
   * Forwards the request to the editor pane.
368
   *
369
   * @param <T> The type of event listener to add.
370
   * @param <U> The type of consumer to add.
371
   * @param event The event that should trigger updates to the listener.
372
   * @param consumer The listener to receive update events.
373
   */
374
  public <T extends Event, U extends T> void addEventListener(
375
    final EventPattern<? super T, ? extends U> event,
376
    final Consumer<? super U> consumer ) {
377
    getEditorPane().addEventListener( event, consumer );
378
  }
379
380
  /**
381
   * Forwards to the editor pane's listeners for keyboard events.
382
   *
383
   * @param map The new input map to replace the existing keyboard listener.
384
   */
385
  public void addEventListener( final InputMap<InputEvent> map ) {
386
    getEditorPane().addEventListener( map );
387
  }
388
389
  /**
390
   * Forwards to the editor pane's listeners for keyboard events.
391
   *
392
   * @param map The existing input map to remove from the keyboard listeners.
393
   */
394
  public void removeEventListener( final InputMap<InputEvent> map ) {
395
    getEditorPane().removeEventListener( map );
396
  }
397
398
  /**
399
   * Forwards to the editor pane's listeners for text change events.
400
   *
401
   * @param listener The listener to notify when the text changes.
402
   */
403
  public void addTextChangeListener( final ChangeListener<String> listener ) {
404
    getEditorPane().addTextChangeListener( listener );
405
  }
406
407
  /**
408
   * Forwards to the editor pane's listeners for caret paragraph change events.
409
   *
410
   * @param listener The listener to notify when the caret changes paragraphs.
411
   */
412
  public void addCaretParagraphListener( final ChangeListener<Integer> listener ) {
413
    getEditorPane().addCaretParagraphListener( listener );
414
  }
415
416
  /**
417
   * Forwards the request to the editor pane.
418
   *
419
   * @return The text to process.
420
   */
421
  public String getEditorText() {
422
    return getEditorPane().getText();
423
  }
424
425
  /**
426
   * Returns the editor pane, or creates one if it doesn't yet exist.
427
   *
428
   * @return The editor pane, never null.
429
   */
430
  public EditorPane getEditorPane() {
431
    if( this.editorPane == null ) {
432
      this.editorPane = new MarkdownEditorPane();
433
    }
434
435
    return this.editorPane;
436
  }
437
438
  private Notifier getNotifyService() {
439439
    return this.alertService;
440440
  }
M src/main/java/com/scrivenvar/FileEditorTabPane.java
3535
import com.scrivenvar.service.Settings;
3636
import com.scrivenvar.service.events.Notification;
37
import com.scrivenvar.service.events.NotifyService;
38
import static com.scrivenvar.service.events.NotifyService.NO;
39
import static com.scrivenvar.service.events.NotifyService.YES;
40
import com.scrivenvar.util.Utils;
41
import java.io.File;
42
import java.nio.file.Path;
43
import java.util.ArrayList;
44
import java.util.List;
45
import java.util.function.Consumer;
46
import java.util.prefs.Preferences;
47
import java.util.stream.Collectors;
48
import javafx.beans.property.ReadOnlyBooleanProperty;
49
import javafx.beans.property.ReadOnlyBooleanWrapper;
50
import javafx.beans.property.ReadOnlyObjectProperty;
51
import javafx.beans.property.ReadOnlyObjectWrapper;
52
import javafx.beans.value.ChangeListener;
53
import javafx.beans.value.ObservableValue;
54
import javafx.collections.ListChangeListener;
55
import javafx.collections.ObservableList;
56
import javafx.event.Event;
57
import javafx.scene.Node;
58
import javafx.scene.control.Alert;
59
import javafx.scene.control.ButtonType;
60
import javafx.scene.control.Tab;
61
import javafx.scene.control.TabPane;
62
import javafx.scene.control.TabPane.TabClosingPolicy;
63
import javafx.scene.input.InputEvent;
64
import javafx.stage.FileChooser;
65
import javafx.stage.FileChooser.ExtensionFilter;
66
import javafx.stage.Window;
67
import org.fxmisc.richtext.StyledTextArea;
68
import org.fxmisc.wellbehaved.event.EventPattern;
69
import org.fxmisc.wellbehaved.event.InputMap;
70
71
/**
72
 * Tab pane for file editors.
73
 *
74
 * @author Karl Tauber and White Magic Software, Ltd.
75
 */
76
public final class FileEditorTabPane extends TabPane {
77
78
  private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter";
79
80
  private final Options options = Services.load( Options.class );
81
  private final Settings settings = Services.load( Settings.class );
82
  private final NotifyService alertService = Services.load(NotifyService.class );
83
84
  private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
86
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
87
88
  /**
89
   * Constructs a new file editor tab pane.
90
   */
91
  public FileEditorTabPane() {
92
    final ObservableList<Tab> tabs = getTabs();
93
94
    setFocusTraversable( false );
95
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
96
97
    addTabSelectionListener(
98
      (ObservableValue<? extends Tab> tabPane,
99
        final Tab oldTab, final Tab newTab) -> {
100
101
        if( newTab != null ) {
102
          activeFileEditor.set( (FileEditorTab)newTab );
103
        }
104
      }
105
    );
106
107
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
108
      for( final Tab tab : tabs ) {
109
        if( ((FileEditorTab)tab).isModified() ) {
110
          this.anyFileEditorModified.set( true );
111
          break;
112
        }
113
      }
114
    };
115
116
    tabs.addListener(
117
      (ListChangeListener<Tab>)change -> {
118
        while( change.next() ) {
119
          if( change.wasAdded() ) {
120
            change.getAddedSubList().stream().forEach( (tab) -> {
121
              ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener );
122
            } );
123
          } else if( change.wasRemoved() ) {
124
            change.getRemoved().stream().forEach( (tab) -> {
125
              ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener );
126
            } );
127
          }
128
        }
129
130
        // Changes in the tabs may also change anyFileEditorModified property
131
        // (e.g. closed modified file)
132
        modifiedListener.changed( null, null, null );
133
      }
134
    );
135
  }
136
137
  /**
138
   * Delegates to the active file editor.
139
   *
140
   * @param <T> Event type.
141
   * @param <U> Consumer type.
142
   * @param event Event to pass to the editor.
143
   * @param consumer Consumer to pass to the editor.
144
   */
145
  public <T extends Event, U extends T> void addEventListener(
146
    final EventPattern<? super T, ? extends U> event,
147
    final Consumer<? super U> consumer ) {
148
    getActiveFileEditor().addEventListener( event, consumer );
149
  }
150
151
  /**
152
   * Delegates to the active file editor pane, and, ultimately, to its text
153
   * area.
154
   *
155
   * @param map The map of methods to events.
156
   */
157
  public void addEventListener( final InputMap<InputEvent> map ) {
158
    getActiveFileEditor().addEventListener( map );
159
  }
160
161
  /**
162
   * Remove a keyboard event listener from the active file editor.
163
   *
164
   * @param map The keyboard events to remove.
165
   */
166
  public void removeEventListener( final InputMap<InputEvent> map ) {
167
    getActiveFileEditor().removeEventListener( map );
168
  }
169
170
  /**
171
   * Allows observers to be notified when the current file editor tab changes.
172
   *
173
   * @param listener The listener to notify of tab change events.
174
   */
175
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
176
    // Observe the tab so that when a new tab is opened or selected,
177
    // a notification is kicked off.
178
    getSelectionModel().selectedItemProperty().addListener( listener );
179
  }
180
181
  /**
182
   * Allows clients to manipulate the editor content directly.
183
   *
184
   * @return The text area for the active file editor.
185
   */
186
  public StyledTextArea getEditor() {
187
    return getActiveFileEditor().getEditorPane().getEditor();
188
  }
189
190
  public FileEditorTab getActiveFileEditor() {
191
    return this.activeFileEditor.get();
192
  }
193
194
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
195
    return this.activeFileEditor.getReadOnlyProperty();
196
  }
197
198
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
199
    return this.anyFileEditorModified.getReadOnlyProperty();
200
  }
201
202
  private FileEditorTab createFileEditor( final Path path ) {
203
    final FileEditorTab tab = new FileEditorTab( path );
204
205
    tab.setOnCloseRequest( e -> {
206
      if( !canCloseEditor( tab ) ) {
207
        e.consume();
208
      }
209
    } );
210
211
    return tab;
212
  }
213
214
  /**
215
   * Called when the user selects New from the File menu.
216
   *
217
   * @return The newly added tab.
218
   */
219
  void newEditor() {
220
    final FileEditorTab tab = createFileEditor( null );
221
222
    getTabs().add( tab );
223
    getSelectionModel().select( tab );
224
  }
225
226
  void openFileDialog() {
227
    final String title = get( "Dialog.file.choose.open.title" );
228
    final FileChooser dialog = createFileChooser( title );
229
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
230
231
    if( files != null ) {
232
      openFiles( files );
233
    }
234
  }
235
236
  /**
237
   * Opens the files into new editors, unless one of those files was a
238
   * definition file. The definition file is loaded into the definition pane,
239
   * but only the first one selected (multiple definition files will result in a
240
   * warning).
241
   *
242
   * @param files The list of non-definition files that the were requested to
243
   * open.
244
   *
245
   * @return A list of files that can be opened in text editors.
246
   */
247
  private void openFiles( final List<File> files ) {
248
    final FileTypePredicate predicate
249
      = new FileTypePredicate( createExtensionFilter( DEFINITION ).getExtensions() );
250
251
    // The user might have opened multiple definitions files. These will
252
    // be discarded from the text editable files.
253
    final List<File> definitions
254
      = files.stream().filter( predicate ).collect( Collectors.toList() );
255
256
    // Create a modifiable list to remove any definition files that were
257
    // opened.
258
    final List<File> editors = new ArrayList<>( files );
259
260
    if( editors.size() > 0 ) {
261
      saveLastDirectory( editors.get( 0 ) );
262
    }
263
264
    editors.removeAll( definitions );
265
266
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
267
    if( editors.size() > 0 ) {
268
      openEditors( editors, 0 );
269
    }
270
271
    if( definitions.size() > 0 ) {
272
      openDefinition( definitions.get( 0 ) );
273
    }
274
  }
275
276
  private void openEditors( final List<File> files, final int activeIndex ) {
277
    final int fileTally = files.size();
278
    final List<Tab> tabs = getTabs();
279
280
    // Close single unmodified "Untitled" tab.
281
    if( tabs.size() == 1 ) {
282
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ));
283
284
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
285
        closeEditor( fileEditor, false );
286
      }
287
    }
288
289
    for( int i = 0; i < fileTally; i++ ) {
290
      final Path path = files.get( i ).toPath();
291
292
      FileEditorTab fileEditorTab = findEditor( path );
293
294
      // Only open new files.
295
      if( fileEditorTab == null ) {
296
        fileEditorTab = createFileEditor( path );
297
        getTabs().add( fileEditorTab );
298
      }
299
300
      // Select the first file in the list.
301
      if( i == activeIndex ) {
302
        getSelectionModel().select( fileEditorTab );
303
      }
304
    }
305
  }
306
307
  /**
308
   * Returns a property that changes when a new definition file is opened.
309
   *
310
   * @return The path to a definition file that was opened.
311
   */
312
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
313
    return getOnOpenDefinitionFile().getReadOnlyProperty();
314
  }
315
316
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
317
    return this.openDefinition;
318
  }
319
320
  /**
321
   * Called when the user has opened a definition file (using the file open
322
   * dialog box). This will replace the current set of definitions for the
323
   * active tab.
324
   *
325
   * @param definition The file to open.
326
   */
327
  private void openDefinition( final File definition ) {
328
    // TODO: Prevent reading this file twice when a new text document is opened.
329
    // (might be a matter of checking the value first).
330
    getOnOpenDefinitionFile().set( definition.toPath() );
331
  }
332
333
  boolean saveEditor( final FileEditorTab fileEditor ) {
334
    if( fileEditor == null || !fileEditor.isModified() ) {
335
      return true;
336
    }
337
338
    if( fileEditor.getPath() == null ) {
339
      getSelectionModel().select( fileEditor );
340
341
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
342
      final File file = fileChooser.showSaveDialog( getWindow() );
343
      if( file == null ) {
344
        return false;
345
      }
346
347
      saveLastDirectory( file );
348
      fileEditor.setPath( file.toPath() );
349
    }
350
351
    return fileEditor.save();
352
  }
353
354
  boolean saveAllEditors() {
355
    boolean success = true;
356
357
    for( FileEditorTab fileEditor : getAllEditors() ) {
358
      if( !saveEditor( fileEditor ) ) {
359
        success = false;
360
      }
361
    }
362
363
    return success;
364
  }
365
366
  /**
367
   * Answers whether the file has had modifications. '
368
   *
369
   * @param tab THe tab to check for modifications.
370
   *
371
   * @return false The file is unmodified.
372
   */
373
  boolean canCloseEditor( final FileEditorTab tab ) {
374
    if( !tab.isModified() ) {
375
      return true;
376
    }
377
378
    final Notification message = getAlertService().createNotification(
379
      Messages.get( "Alert.file.close.title" ),
380
      Messages.get( "Alert.file.close.text" ),
381
      tab.getText()
382
    );
383
384
    final Alert alert = getAlertService().createConfirmation( message );
385
    final ButtonType response = alert.showAndWait().get();
386
387
    return response == YES ? saveEditor( tab ) : response == NO;
388
  }
389
390
  private NotifyService getAlertService() {
391
    return this.alertService;
392
  }
393
394
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
395
    if( fileEditor == null ) {
396
      return true;
397
    }
398
399
    final Tab tab = fileEditor;
400
401
    if( save ) {
402
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
403
      Event.fireEvent( tab, event );
404
405
      if( event.isConsumed() ) {
406
        return false;
407
      }
408
    }
409
410
    getTabs().remove( tab );
411
412
    if( tab.getOnClosed() != null ) {
413
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
414
    }
415
416
    return true;
417
  }
418
419
  boolean closeAllEditors() {
420
    final FileEditorTab[] allEditors = getAllEditors();
421
    final FileEditorTab activeEditor = getActiveFileEditor();
422
423
    // try to save active tab first because in case the user decides to cancel,
424
    // then it stays active
425
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
426
      return false;
427
    }
428
429
    // This should be called any time a tab changes.
430
    persistPreferences();
431
432
    // save modified tabs
433
    for( int i = 0; i < allEditors.length; i++ ) {
434
      final FileEditorTab fileEditor = allEditors[ i ];
435
436
      if( fileEditor == activeEditor ) {
437
        continue;
438
      }
439
440
      if( fileEditor.isModified() ) {
441
        // activate the modified tab to make its modified content visible to the user
442
        getSelectionModel().select( i );
443
444
        if( !canCloseEditor( fileEditor ) ) {
445
          return false;
446
        }
447
      }
448
    }
449
450
    // Close all tabs.
451
    for( final FileEditorTab fileEditor : allEditors ) {
452
      if( !closeEditor( fileEditor, false ) ) {
453
        return false;
454
      }
455
    }
456
457
    return getTabs().isEmpty();
458
  }
459
460
  private FileEditorTab[] getAllEditors() {
461
    final ObservableList<Tab> tabs = getTabs();
462
    final int length = tabs.size();
463
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
464
465
    for( int i = 0; i < length; i++ ) {
466
      allEditors[ i ] = (FileEditorTab)tabs.get( i );
467
    }
468
469
    return allEditors;
470
  }
471
472
  /**
473
   * Returns the file editor tab that has the given path.
474
   *
475
   * @return null No file editor tab for the given path was found.
476
   */
477
  private FileEditorTab findEditor( final Path path ) {
478
    for( final Tab tab : getTabs() ) {
479
      final FileEditorTab fileEditor = (FileEditorTab)tab;
480
481
      if( fileEditor.isPath( path ) ) {
482
        return fileEditor;
483
      }
484
    }
485
486
    return null;
487
  }
488
489
  private FileChooser createFileChooser( String title ) {
490
    final FileChooser fileChooser = new FileChooser();
491
492
    fileChooser.setTitle( title );
493
    fileChooser.getExtensionFilters().addAll(
494
      createExtensionFilters() );
495
496
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
497
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
498
499
    if( !file.isDirectory() ) {
500
      file = new File( "." );
501
    }
502
503
    fileChooser.setInitialDirectory( file );
504
    return fileChooser;
505
  }
506
507
  private List<ExtensionFilter> createExtensionFilters() {
508
    final List<ExtensionFilter> list = new ArrayList<>();
509
510
    // TODO: Return a list of all properties that match the filter prefix.
511
    // This will allow dynamic filters to be added and removed just by
512
    // updating the properties file.
513
    list.add( createExtensionFilter( MARKDOWN ) );
514
    list.add( createExtensionFilter( DEFINITION ) );
515
    list.add( createExtensionFilter( XML ) );
516
    list.add( createExtensionFilter( ALL ) );
517
    return list;
518
  }
519
520
  /**
521
   * Returns a filter for file name extensions recognized by the application
522
   * that can be opened by the user.
523
   *
524
   * @param filetype Used to find the globbing pattern for extensions.
525
   *
526
   * @return A filename filter suitable for use by a FileDialog instance.
527
   */
528
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
529
    final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype );
530
    final String eKey = String.format("%s.%s", GLOB_PREFIX_FILE, filetype );
37
import static com.scrivenvar.service.events.Notifier.NO;
38
import static com.scrivenvar.service.events.Notifier.YES;
39
import com.scrivenvar.util.Utils;
40
import java.io.File;
41
import java.nio.file.Path;
42
import java.util.ArrayList;
43
import java.util.List;
44
import java.util.function.Consumer;
45
import java.util.prefs.Preferences;
46
import java.util.stream.Collectors;
47
import javafx.beans.property.ReadOnlyBooleanProperty;
48
import javafx.beans.property.ReadOnlyBooleanWrapper;
49
import javafx.beans.property.ReadOnlyObjectProperty;
50
import javafx.beans.property.ReadOnlyObjectWrapper;
51
import javafx.beans.value.ChangeListener;
52
import javafx.beans.value.ObservableValue;
53
import javafx.collections.ListChangeListener;
54
import javafx.collections.ObservableList;
55
import javafx.event.Event;
56
import javafx.scene.Node;
57
import javafx.scene.control.Alert;
58
import javafx.scene.control.ButtonType;
59
import javafx.scene.control.Tab;
60
import javafx.scene.control.TabPane;
61
import javafx.scene.control.TabPane.TabClosingPolicy;
62
import javafx.scene.input.InputEvent;
63
import javafx.stage.FileChooser;
64
import javafx.stage.FileChooser.ExtensionFilter;
65
import javafx.stage.Window;
66
import org.fxmisc.richtext.StyledTextArea;
67
import org.fxmisc.wellbehaved.event.EventPattern;
68
import org.fxmisc.wellbehaved.event.InputMap;
69
import com.scrivenvar.service.events.Notifier;
70
import static com.scrivenvar.Messages.get;
71
72
/**
73
 * Tab pane for file editors.
74
 *
75
 * @author Karl Tauber and White Magic Software, Ltd.
76
 */
77
public final class FileEditorTabPane extends TabPane {
78
79
  private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter";
80
81
  private final Options options = Services.load( Options.class );
82
  private final Settings settings = Services.load( Settings.class );
83
  private final Notifier notifyService = Services.load(Notifier.class );
84
85
  private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>();
86
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
87
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
88
89
  /**
90
   * Constructs a new file editor tab pane.
91
   */
92
  public FileEditorTabPane() {
93
    final ObservableList<Tab> tabs = getTabs();
94
95
    setFocusTraversable( false );
96
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
97
98
    addTabSelectionListener(
99
      (ObservableValue<? extends Tab> tabPane,
100
        final Tab oldTab, final Tab newTab) -> {
101
102
        if( newTab != null ) {
103
          activeFileEditor.set( (FileEditorTab)newTab );
104
        }
105
      }
106
    );
107
108
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
109
      for( final Tab tab : tabs ) {
110
        if( ((FileEditorTab)tab).isModified() ) {
111
          this.anyFileEditorModified.set( true );
112
          break;
113
        }
114
      }
115
    };
116
117
    tabs.addListener(
118
      (ListChangeListener<Tab>)change -> {
119
        while( change.next() ) {
120
          if( change.wasAdded() ) {
121
            change.getAddedSubList().stream().forEach( (tab) -> {
122
              ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener );
123
            } );
124
          } else if( change.wasRemoved() ) {
125
            change.getRemoved().stream().forEach( (tab) -> {
126
              ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener );
127
            } );
128
          }
129
        }
130
131
        // Changes in the tabs may also change anyFileEditorModified property
132
        // (e.g. closed modified file)
133
        modifiedListener.changed( null, null, null );
134
      }
135
    );
136
  }
137
138
  /**
139
   * Delegates to the active file editor.
140
   *
141
   * @param <T> Event type.
142
   * @param <U> Consumer type.
143
   * @param event Event to pass to the editor.
144
   * @param consumer Consumer to pass to the editor.
145
   */
146
  public <T extends Event, U extends T> void addEventListener(
147
    final EventPattern<? super T, ? extends U> event,
148
    final Consumer<? super U> consumer ) {
149
    getActiveFileEditor().addEventListener( event, consumer );
150
  }
151
152
  /**
153
   * Delegates to the active file editor pane, and, ultimately, to its text
154
   * area.
155
   *
156
   * @param map The map of methods to events.
157
   */
158
  public void addEventListener( final InputMap<InputEvent> map ) {
159
    getActiveFileEditor().addEventListener( map );
160
  }
161
162
  /**
163
   * Remove a keyboard event listener from the active file editor.
164
   *
165
   * @param map The keyboard events to remove.
166
   */
167
  public void removeEventListener( final InputMap<InputEvent> map ) {
168
    getActiveFileEditor().removeEventListener( map );
169
  }
170
171
  /**
172
   * Allows observers to be notified when the current file editor tab changes.
173
   *
174
   * @param listener The listener to notify of tab change events.
175
   */
176
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
177
    // Observe the tab so that when a new tab is opened or selected,
178
    // a notification is kicked off.
179
    getSelectionModel().selectedItemProperty().addListener( listener );
180
  }
181
182
  /**
183
   * Allows clients to manipulate the editor content directly.
184
   *
185
   * @return The text area for the active file editor.
186
   */
187
  public StyledTextArea getEditor() {
188
    return getActiveFileEditor().getEditorPane().getEditor();
189
  }
190
191
  public FileEditorTab getActiveFileEditor() {
192
    return this.activeFileEditor.get();
193
  }
194
195
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
196
    return this.activeFileEditor.getReadOnlyProperty();
197
  }
198
199
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
200
    return this.anyFileEditorModified.getReadOnlyProperty();
201
  }
202
203
  private FileEditorTab createFileEditor( final Path path ) {
204
    final FileEditorTab tab = new FileEditorTab( path );
205
206
    tab.setOnCloseRequest( e -> {
207
      if( !canCloseEditor( tab ) ) {
208
        e.consume();
209
      }
210
    } );
211
212
    return tab;
213
  }
214
215
  /**
216
   * Called when the user selects New from the File menu.
217
   *
218
   * @return The newly added tab.
219
   */
220
  void newEditor() {
221
    final FileEditorTab tab = createFileEditor( null );
222
223
    getTabs().add( tab );
224
    getSelectionModel().select( tab );
225
  }
226
227
  void openFileDialog() {
228
    final String title = get( "Dialog.file.choose.open.title" );
229
    final FileChooser dialog = createFileChooser( title );
230
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
231
232
    if( files != null ) {
233
      openFiles( files );
234
    }
235
  }
236
237
  /**
238
   * Opens the files into new editors, unless one of those files was a
239
   * definition file. The definition file is loaded into the definition pane,
240
   * but only the first one selected (multiple definition files will result in a
241
   * warning).
242
   *
243
   * @param files The list of non-definition files that the were requested to
244
   * open.
245
   *
246
   * @return A list of files that can be opened in text editors.
247
   */
248
  private void openFiles( final List<File> files ) {
249
    final FileTypePredicate predicate
250
      = new FileTypePredicate( createExtensionFilter( DEFINITION ).getExtensions() );
251
252
    // The user might have opened multiple definitions files. These will
253
    // be discarded from the text editable files.
254
    final List<File> definitions
255
      = files.stream().filter( predicate ).collect( Collectors.toList() );
256
257
    // Create a modifiable list to remove any definition files that were
258
    // opened.
259
    final List<File> editors = new ArrayList<>( files );
260
261
    if( editors.size() > 0 ) {
262
      saveLastDirectory( editors.get( 0 ) );
263
    }
264
265
    editors.removeAll( definitions );
266
267
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
268
    if( editors.size() > 0 ) {
269
      openEditors( editors, 0 );
270
    }
271
272
    if( definitions.size() > 0 ) {
273
      openDefinition( definitions.get( 0 ) );
274
    }
275
  }
276
277
  private void openEditors( final List<File> files, final int activeIndex ) {
278
    final int fileTally = files.size();
279
    final List<Tab> tabs = getTabs();
280
281
    // Close single unmodified "Untitled" tab.
282
    if( tabs.size() == 1 ) {
283
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ));
284
285
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
286
        closeEditor( fileEditor, false );
287
      }
288
    }
289
290
    for( int i = 0; i < fileTally; i++ ) {
291
      final Path path = files.get( i ).toPath();
292
293
      FileEditorTab fileEditorTab = findEditor( path );
294
295
      // Only open new files.
296
      if( fileEditorTab == null ) {
297
        fileEditorTab = createFileEditor( path );
298
        getTabs().add( fileEditorTab );
299
      }
300
301
      // Select the first file in the list.
302
      if( i == activeIndex ) {
303
        getSelectionModel().select( fileEditorTab );
304
      }
305
    }
306
  }
307
308
  /**
309
   * Returns a property that changes when a new definition file is opened.
310
   *
311
   * @return The path to a definition file that was opened.
312
   */
313
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
314
    return getOnOpenDefinitionFile().getReadOnlyProperty();
315
  }
316
317
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
318
    return this.openDefinition;
319
  }
320
321
  /**
322
   * Called when the user has opened a definition file (using the file open
323
   * dialog box). This will replace the current set of definitions for the
324
   * active tab.
325
   *
326
   * @param definition The file to open.
327
   */
328
  private void openDefinition( final File definition ) {
329
    // TODO: Prevent reading this file twice when a new text document is opened.
330
    // (might be a matter of checking the value first).
331
    getOnOpenDefinitionFile().set( definition.toPath() );
332
  }
333
334
  boolean saveEditor( final FileEditorTab fileEditor ) {
335
    if( fileEditor == null || !fileEditor.isModified() ) {
336
      return true;
337
    }
338
339
    if( fileEditor.getPath() == null ) {
340
      getSelectionModel().select( fileEditor );
341
342
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
343
      final File file = fileChooser.showSaveDialog( getWindow() );
344
      if( file == null ) {
345
        return false;
346
      }
347
348
      saveLastDirectory( file );
349
      fileEditor.setPath( file.toPath() );
350
    }
351
352
    return fileEditor.save();
353
  }
354
355
  boolean saveAllEditors() {
356
    boolean success = true;
357
358
    for( FileEditorTab fileEditor : getAllEditors() ) {
359
      if( !saveEditor( fileEditor ) ) {
360
        success = false;
361
      }
362
    }
363
364
    return success;
365
  }
366
367
  /**
368
   * Answers whether the file has had modifications. '
369
   *
370
   * @param tab THe tab to check for modifications.
371
   *
372
   * @return false The file is unmodified.
373
   */
374
  boolean canCloseEditor( final FileEditorTab tab ) {
375
    if( !tab.isModified() ) {
376
      return true;
377
    }
378
379
    final Notification message = getNotifyService().createNotification(
380
      Messages.get( "Alert.file.close.title" ),
381
      Messages.get( "Alert.file.close.text" ),
382
      tab.getText()
383
    );
384
385
    final Alert alert = getNotifyService().createConfirmation(
386
      getWindow(), message );
387
    final ButtonType response = alert.showAndWait().get();
388
389
    return response == YES ? saveEditor( tab ) : response == NO;
390
  }
391
392
  private Notifier getNotifyService() {
393
    return this.notifyService;
394
  }
395
396
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
397
    if( fileEditor == null ) {
398
      return true;
399
    }
400
401
    final Tab tab = fileEditor;
402
403
    if( save ) {
404
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
405
      Event.fireEvent( tab, event );
406
407
      if( event.isConsumed() ) {
408
        return false;
409
      }
410
    }
411
412
    getTabs().remove( tab );
413
414
    if( tab.getOnClosed() != null ) {
415
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
416
    }
417
418
    return true;
419
  }
420
421
  boolean closeAllEditors() {
422
    final FileEditorTab[] allEditors = getAllEditors();
423
    final FileEditorTab activeEditor = getActiveFileEditor();
424
425
    // try to save active tab first because in case the user decides to cancel,
426
    // then it stays active
427
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
428
      return false;
429
    }
430
431
    // This should be called any time a tab changes.
432
    persistPreferences();
433
434
    // save modified tabs
435
    for( int i = 0; i < allEditors.length; i++ ) {
436
      final FileEditorTab fileEditor = allEditors[ i ];
437
438
      if( fileEditor == activeEditor ) {
439
        continue;
440
      }
441
442
      if( fileEditor.isModified() ) {
443
        // activate the modified tab to make its modified content visible to the user
444
        getSelectionModel().select( i );
445
446
        if( !canCloseEditor( fileEditor ) ) {
447
          return false;
448
        }
449
      }
450
    }
451
452
    // Close all tabs.
453
    for( final FileEditorTab fileEditor : allEditors ) {
454
      if( !closeEditor( fileEditor, false ) ) {
455
        return false;
456
      }
457
    }
458
459
    return getTabs().isEmpty();
460
  }
461
462
  private FileEditorTab[] getAllEditors() {
463
    final ObservableList<Tab> tabs = getTabs();
464
    final int length = tabs.size();
465
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
466
467
    for( int i = 0; i < length; i++ ) {
468
      allEditors[ i ] = (FileEditorTab)tabs.get( i );
469
    }
470
471
    return allEditors;
472
  }
473
474
  /**
475
   * Returns the file editor tab that has the given path.
476
   *
477
   * @return null No file editor tab for the given path was found.
478
   */
479
  private FileEditorTab findEditor( final Path path ) {
480
    for( final Tab tab : getTabs() ) {
481
      final FileEditorTab fileEditor = (FileEditorTab)tab;
482
483
      if( fileEditor.isPath( path ) ) {
484
        return fileEditor;
485
      }
486
    }
487
488
    return null;
489
  }
490
491
  private FileChooser createFileChooser( String title ) {
492
    final FileChooser fileChooser = new FileChooser();
493
494
    fileChooser.setTitle( title );
495
    fileChooser.getExtensionFilters().addAll(
496
      createExtensionFilters() );
497
498
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
499
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
500
501
    if( !file.isDirectory() ) {
502
      file = new File( "." );
503
    }
504
505
    fileChooser.setInitialDirectory( file );
506
    return fileChooser;
507
  }
508
509
  private List<ExtensionFilter> createExtensionFilters() {
510
    final List<ExtensionFilter> list = new ArrayList<>();
511
512
    // TODO: Return a list of all properties that match the filter prefix.
513
    // This will allow dynamic filters to be added and removed just by
514
    // updating the properties file.
515
    list.add( createExtensionFilter( MARKDOWN ) );
516
    list.add( createExtensionFilter( DEFINITION ) );
517
    list.add( createExtensionFilter( XML ) );
518
    list.add( createExtensionFilter( ALL ) );
519
    return list;
520
  }
521
522
  /**
523
   * Returns a filter for file name extensions recognized by the application
524
   * that can be opened by the user.
525
   *
526
   * @param filetype Used to find the globbing pattern for extensions.
527
   *
528
   * @return A filename filter suitable for use by a FileDialog instance.
529
   */
530
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
531
    final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype );
532
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
531533
532534
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
M src/main/java/com/scrivenvar/Main.java
3737
import javafx.scene.image.Image;
3838
import javafx.stage.Stage;
39
import com.scrivenvar.service.events.NotifyService;
39
import com.scrivenvar.service.events.Notifier;
4040
4141
/**
...
7979
  public void start( final Stage stage ) throws Exception {
8080
    initApplication();
81
    initNotifyService();
8182
    initState( stage );
8283
    initStage( stage );
83
    initAlertService();
84
    initWatchDog();
84
    initSnitch();
8585
8686
    stage.show();
...
9393
  private void initApplication() {
9494
    app = this;
95
  }
96
97
  /**
98
   * Constructs the notify service and appends the main window to the list of
99
   * notification observers.
100
   */
101
  private void initNotifyService() {
102
    final Notifier service = Services.load(Notifier.class );
103
    service.addObserver( getMainWindow() );
95104
  }
96105
...
108117
    stage.setTitle( getApplicationTitle() );
109118
    stage.setScene( getScene() );
110
  }
111
112
  private void initAlertService() {
113
    final NotifyService service = Services.load(NotifyService.class );
114
    service.setWindow( getScene().getWindow() );
115119
  }
116120
117
  private void initWatchDog() {
118
    setSnitchThread( new Thread( getWatchDog() ) );
121
  private void initSnitch() {
122
    setSnitchThread( new Thread( getSnitch() ) );
119123
    getSnitchThread().start();
120124
  }
...
127131
  @Override
128132
  public void stop() throws InterruptedException {
129
    getWatchDog().stop();
133
    getSnitch().stop();
130134
131135
    final Thread thread = getSnitchThread();
132136
133137
    if( thread != null ) {
134138
      thread.interrupt();
135139
      thread.join();
136140
    }
137141
  }
138142
139
  private synchronized Snitch getWatchDog() {
143
  private synchronized Snitch getSnitch() {
140144
    if( this.snitch == null ) {
141145
      this.snitch = Services.load( Snitch.class );
142146
    }
143
    
147
144148
    return this.snitch;
145149
  }
M src/main/java/com/scrivenvar/MainWindow.java
3939
import com.scrivenvar.service.Options;
4040
import com.scrivenvar.service.Snitch;
41
import com.scrivenvar.util.Action;
42
import com.scrivenvar.util.ActionUtils;
43
import static com.scrivenvar.util.StageState.*;
44
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
45
import java.nio.file.Path;
46
import java.util.HashMap;
47
import java.util.Map;
48
import java.util.Observable;
49
import java.util.Observer;
50
import java.util.function.Function;
51
import java.util.prefs.Preferences;
52
import javafx.application.Platform;
53
import javafx.beans.binding.Bindings;
54
import javafx.beans.binding.BooleanBinding;
55
import javafx.beans.property.BooleanProperty;
56
import javafx.beans.property.SimpleBooleanProperty;
57
import javafx.beans.value.ObservableBooleanValue;
58
import javafx.beans.value.ObservableValue;
59
import javafx.collections.ListChangeListener.Change;
60
import javafx.collections.ObservableList;
61
import static javafx.event.Event.fireEvent;
62
import javafx.scene.Node;
63
import javafx.scene.Scene;
64
import javafx.scene.control.Alert;
65
import javafx.scene.control.Alert.AlertType;
66
import javafx.scene.control.Menu;
67
import javafx.scene.control.MenuBar;
68
import javafx.scene.control.SplitPane;
69
import javafx.scene.control.Tab;
70
import javafx.scene.control.ToolBar;
71
import javafx.scene.control.TreeView;
72
import javafx.scene.image.Image;
73
import javafx.scene.image.ImageView;
74
import static javafx.scene.input.KeyCode.ESCAPE;
75
import javafx.scene.input.KeyEvent;
76
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
77
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
78
import javafx.scene.layout.BorderPane;
79
import javafx.scene.layout.VBox;
80
import javafx.stage.Window;
81
import javafx.stage.WindowEvent;
82
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
83
84
/**
85
 * Main window containing a tab pane in the center for file editors.
86
 *
87
 * @author Karl Tauber and White Magic Software, Ltd.
88
 */
89
public class MainWindow implements Observer {
90
91
  private final Options options = Services.load( Options.class );
92
  private final Snitch snitch = Services.load( Snitch.class );
93
94
  private Scene scene;
95
  private MenuBar menuBar;
96
97
  private DefinitionSource definitionSource;
98
  private DefinitionPane definitionPane;
99
  private FileEditorTabPane fileEditorPane;
100
  private HTMLPreviewPane previewPane;
101
102
  /**
103
   * Prevent re-instantiation processing classes.
104
   */
105
  private Map<FileEditorTab, Processor<String>> processors;
106
107
  public MainWindow() {
108
    initLayout();
109
    initDefinitionListener();
110
    initTabAddedListener();
111
    initTabChangedListener();
112
    initPreferences();
113
    initWatchDog();
114
  }
115
116
  /**
117
   * Listen for file editor tab pane to receive an open definition source event.
118
   */
119
  private void initDefinitionListener() {
120
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
121
      (ObservableValue<? extends Path> definitionFile,
122
        final Path oldPath, final Path newPath) -> {
123
        openDefinition( newPath );
124
125
        // Indirectly refresh the resolved map.
126
        setProcessors( null );
127
        
128
        // Will create new processors and therefore a new resolved map.
129
        refreshSelectedTab( getActiveFileEditor() );
130
        
131
        updateDefinitionPane();
132
      }
133
    );
134
  }
135
136
  /**
137
   * When tabs are added, hook the various change listeners onto the new tab so
138
   * that the preview pane refreshes as necessary.
139
   */
140
  private void initTabAddedListener() {
141
    final FileEditorTabPane editorPane = getFileEditorPane();
142
143
    // Make sure the text processor kicks off when new files are opened.
144
    final ObservableList<Tab> tabs = editorPane.getTabs();
145
146
    // Update the preview pane on tab changes.
147
    tabs.addListener(
148
      (final Change<? extends Tab> change) -> {
149
        while( change.next() ) {
150
          if( change.wasAdded() ) {
151
            // Multiple tabs can be added simultaneously.
152
            for( final Tab newTab : change.getAddedSubList() ) {
153
              final FileEditorTab tab = (FileEditorTab)newTab;
154
155
              initTextChangeListener( tab );
156
              initCaretParagraphListener( tab );
157
              initVariableNameInjector( tab );
158
            }
159
          }
160
        }
161
      }
162
    );
163
  }
164
165
  /**
166
   * Reloads the preferences from the previous load.
167
   */
168
  private void initPreferences() {
169
    restoreDefinitionSource();
170
    getFileEditorPane().restorePreferences();
171
    updateDefinitionPane();
172
  }
173
174
  /**
175
   * Listen for new tab selection events.
176
   */
177
  private void initTabChangedListener() {
178
    final FileEditorTabPane editorPane = getFileEditorPane();
179
180
    // Update the preview pane changing tabs.
181
    editorPane.addTabSelectionListener(
182
      (ObservableValue<? extends Tab> tabPane,
183
        final Tab oldTab, final Tab newTab) -> {
184
185
        // If there was no old tab, then this is a first time load, which
186
        // can be ignored.
187
        if( oldTab != null ) {
188
          if( newTab == null ) {
189
            closeRemainingTab();
190
          } else {
191
            // Update the preview with the edited text.
192
            refreshSelectedTab( (FileEditorTab)newTab );
193
          }
194
        }
195
      }
196
    );
197
  }
198
199
  private void initTextChangeListener( final FileEditorTab tab ) {
200
    tab.addTextChangeListener(
201
      (ObservableValue<? extends String> editor,
202
        final String oldValue, final String newValue) -> {
203
        refreshSelectedTab( tab );
204
      }
205
    );
206
  }
207
208
  private void initCaretParagraphListener( final FileEditorTab tab ) {
209
    tab.addCaretParagraphListener(
210
      (ObservableValue<? extends Integer> editor,
211
        final Integer oldValue, final Integer newValue) -> {
212
        refreshSelectedTab( tab );
213
      }
214
    );
215
  }
216
217
  private void initVariableNameInjector( final FileEditorTab tab ) {
218
    VariableNameInjector.listen( tab, getDefinitionPane() );
219
  }
220
221
  /**
222
   * Watch for changes to external files. In particular, this awaits
223
   * modifications to any XSL files associated with XML files being edited. When
224
   * an XSL file is modified (external to the application), the watchdog's ears
225
   * perk up and the file is reloaded. This keeps the XSL transformation up to
226
   * date with what's on the file system.
227
   */
228
  private void initWatchDog() {
229
    getSnitch().addObserver( this );
230
  }
231
232
  /**
233
   * Called whenever the preview pane becomes out of sync with the file editor
234
   * tab. This can be called when the text changes, the caret paragraph changes,
235
   * or the file tab changes.
236
   *
237
   * @param tab The file editor tab that has been changed in some fashion.
238
   */
239
  private void refreshSelectedTab( final FileEditorTab tab ) {
240
    if( tab.isFileOpen() ) {
241
      getPreviewPane().setPath( tab.getPath() );
242
243
      Processor<String> processor = getProcessors().get( tab );
244
245
      if( processor == null ) {
246
        processor = createProcessor( tab );
247
        getProcessors().put( tab, processor );
248
      }
249
250
      processor.processChain( tab.getEditorText() );
251
    }
252
  }
253
254
  /**
255
   * Returns the variable map of interpolated definitions.
256
   *
257
   * @return A map to help dereference variables.
258
   */
259
  private Map<String, String> getResolvedMap() {
260
    return getDefinitionSource().getResolvedMap();
261
  }
262
263
  /**
264
   * Returns the root node for the hierarchical definition source.
265
   *
266
   * @return Data to display in the definition pane.
267
   */
268
  private TreeView<String> getTreeView() {
269
    try {
270
      return getDefinitionSource().asTreeView();
271
    } catch( Exception e ) {
272
      alert( e );
273
    }
274
275
    return new TreeView<>();
276
  }
277
278
  /**
279
   * Called when a definition source is opened.
280
   *
281
   * @param path Path to the definition source that was opened.
282
   */
283
  private void openDefinition( final Path path ) {
284
    try {
285
      final DefinitionSource ds = createDefinitionSource( path.toString() );
286
      setDefinitionSource( ds );
287
      storeDefinitionSource();
288
      updateDefinitionPane();
289
    } catch( final Exception e ) {
290
      alert( e );
291
    }
292
  }
293
294
  private void updateDefinitionPane() {
295
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
296
  }
297
298
  private void restoreDefinitionSource() {
299
    final Preferences preferences = getPreferences();
300
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
301
302
    // If there's no definition source set, don't try to load it.
303
    if( source != null ) {
304
      setDefinitionSource( createDefinitionSource( source ) );
305
    }
306
  }
307
308
  private void storeDefinitionSource() {
309
    final Preferences preferences = getPreferences();
310
    final DefinitionSource ds = getDefinitionSource();
311
312
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
313
  }
314
315
  /**
316
   * Called when the last open tab is closed to clear the preview pane.
317
   */
318
  private void closeRemainingTab() {
319
    getPreviewPane().clear();
320
  }
321
322
  /**
323
   * Called when an exception occurs that warrants the user's attention.
324
   *
325
   * @param e The exception with a message that the user should know about.
326
   */
327
  private void alert( final Exception e ) {
328
    // TODO: Update the status bar or do something clever with the error.
329
  }
330
331
  //---- File actions -------------------------------------------------------
332
  /**
333
   * Called when a file has been modified.
334
   *
335
   * @param snitch The watchdog file monitoring instance.
336
   * @param file The file that was modified.
337
   */
338
  @Override
339
  public void update( final Observable snitch, final Object file ) {
340
    if( file instanceof Path ) {
341
      update( (Path)file );
342
    }
343
  }
344
345
  /**
346
   * Called when a file has been modified.
347
   *
348
   * @param file Path to the modified file.
349
   */
350
  private void update( final Path file ) {
351
    // Avoid throwing IllegalStateException by running from a non-JavaFX thread.
352
    Platform.runLater(
353
      () -> {
354
        // Brute-force XSLT file reload by re-instantiating all processors.
355
        resetProcessors();
356
        refreshSelectedTab( getActiveFileEditor() );
357
      }
358
    );
359
  }
360
361
  /**
362
   * After resetting the processors, they will refresh anew to be up-to-date
363
   * with the files (text and definition) currently loaded into the editor.
364
   */
365
  private void resetProcessors() {
366
    getProcessors().clear();
367
  }
368
369
  //---- File actions -------------------------------------------------------
370
  private void fileNew() {
371
    getFileEditorPane().newEditor();
372
  }
373
374
  private void fileOpen() {
375
    getFileEditorPane().openFileDialog();
376
  }
377
378
  private void fileClose() {
379
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
380
  }
381
382
  private void fileCloseAll() {
383
    getFileEditorPane().closeAllEditors();
384
  }
385
386
  private void fileSave() {
387
    getFileEditorPane().saveEditor( getActiveFileEditor() );
388
  }
389
390
  private void fileSaveAll() {
391
    getFileEditorPane().saveAllEditors();
392
  }
393
394
  private void fileExit() {
395
    final Window window = getWindow();
396
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
397
  }
398
399
  //---- Help actions -------------------------------------------------------
400
  private void helpAbout() {
401
    Alert alert = new Alert( AlertType.INFORMATION );
402
    alert.setTitle( get( "Dialog.about.title" ) );
403
    alert.setHeaderText( get( "Dialog.about.header" ) );
404
    alert.setContentText( get( "Dialog.about.content" ) );
405
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
406
    alert.initOwner( getWindow() );
407
408
    alert.showAndWait();
409
  }
410
411
  //---- Convenience accessors ----------------------------------------------
412
  private float getFloat( final String key, final float defaultValue ) {
413
    return getPreferences().getFloat( key, defaultValue );
414
  }
415
416
  private Preferences getPreferences() {
417
    return getOptions().getState();
418
  }
419
420
  private Window getWindow() {
421
    return getScene().getWindow();
422
  }
423
424
  private MarkdownEditorPane getActiveEditor() {
425
    final EditorPane pane = getActiveFileEditor().getEditorPane();
426
427
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
428
  }
429
430
  private FileEditorTab getActiveFileEditor() {
431
    return getFileEditorPane().getActiveFileEditor();
432
  }
433
434
  //---- Member accessors ---------------------------------------------------
435
  private void setScene( Scene scene ) {
436
    this.scene = scene;
437
  }
438
439
  public Scene getScene() {
440
    return this.scene;
441
  }
442
443
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
444
    this.processors = map;
445
  }
446
447
  private Map<FileEditorTab, Processor<String>> getProcessors() {
448
    if( this.processors == null ) {
449
      setProcessors( new HashMap<>() );
450
    }
451
452
    return this.processors;
453
  }
454
455
  private FileEditorTabPane getFileEditorPane() {
456
    if( this.fileEditorPane == null ) {
457
      this.fileEditorPane = createFileEditorPane();
458
    }
459
460
    return this.fileEditorPane;
461
  }
462
463
  private HTMLPreviewPane getPreviewPane() {
464
    if( this.previewPane == null ) {
465
      this.previewPane = createPreviewPane();
466
    }
467
468
    return this.previewPane;
469
  }
470
471
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
472
    this.definitionSource = definitionSource;
473
  }
474
475
  private DefinitionSource getDefinitionSource() {
476
    if( this.definitionSource == null ) {
477
      this.definitionSource = new EmptyDefinitionSource();
478
    }
479
480
    return this.definitionSource;
481
  }
482
483
  private DefinitionPane getDefinitionPane() {
484
    if( this.definitionPane == null ) {
485
      this.definitionPane = createDefinitionPane();
486
    }
487
488
    return this.definitionPane;
489
  }
490
491
  private Options getOptions() {
492
    return this.options;
493
  }
494
495
  private Snitch getSnitch() {
496
    return this.snitch;
497
  }
498
499
  public void setMenuBar( MenuBar menuBar ) {
500
    this.menuBar = menuBar;
501
  }
502
503
  public MenuBar getMenuBar() {
504
    return this.menuBar;
505
  }
506
507
  //---- Member creators ----------------------------------------------------
508
  /**
509
   * Factory to create processors that are suited to different file types.
510
   *
511
   * @param tab The tab that is subjected to processing.
512
   *
513
   * @return A processor suited to the file type specified by the tab's path.
514
   */
515
  private Processor<String> createProcessor( final FileEditorTab tab ) {
516
    return createProcessorFactory().createProcessor( tab );
517
  }
518
519
  private ProcessorFactory createProcessorFactory() {
520
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
521
  }
522
523
  private DefinitionSource createDefinitionSource( final String path ) {
524
    return createDefinitionFactory().createDefinitionSource( path );
525
  }
526
527
  /**
528
   * Create an editor pane to hold file editor tabs.
529
   *
530
   * @return A new instance, never null.
531
   */
532
  private FileEditorTabPane createFileEditorPane() {
533
    return new FileEditorTabPane();
534
  }
535
536
  private HTMLPreviewPane createPreviewPane() {
537
    return new HTMLPreviewPane();
538
  }
539
540
  private DefinitionPane createDefinitionPane() {
541
    return new DefinitionPane( getTreeView() );
542
  }
543
544
  private DefinitionFactory createDefinitionFactory() {
545
    return new DefinitionFactory();
546
  }
547
548
  private Node createMenuBar() {
549
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
550
551
    // File actions
552
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
553
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
554
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
555
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
556
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
557
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
558
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
559
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
560
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
561
562
    // Edit actions
563
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
564
      e -> getActiveEditor().undo(),
565
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
566
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
567
      e -> getActiveEditor().redo(),
568
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
569
570
    // Insert actions
571
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
572
      e -> getActiveEditor().surroundSelection( "**", "**" ),
573
      activeFileEditorIsNull );
574
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
575
      e -> getActiveEditor().surroundSelection( "*", "*" ),
576
      activeFileEditorIsNull );
577
    Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
578
      e -> getActiveEditor().surroundSelection( "^", "^" ),
579
      activeFileEditorIsNull );
580
    Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
581
      e -> getActiveEditor().surroundSelection( "~", "~" ),
582
      activeFileEditorIsNull );
583
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
584
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
585
      activeFileEditorIsNull );
586
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
587
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
588
      activeFileEditorIsNull );
589
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
590
      e -> getActiveEditor().surroundSelection( "`", "`" ),
591
      activeFileEditorIsNull );
592
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
593
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
594
      activeFileEditorIsNull );
595
596
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
597
      e -> getActiveEditor().insertLink(),
598
      activeFileEditorIsNull );
599
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
600
      e -> getActiveEditor().insertImage(),
601
      activeFileEditorIsNull );
602
603
    final Action[] headers = new Action[ 6 ];
604
605
    // Insert header actions (H1 ... H6)
606
    for( int i = 1; i <= 6; i++ ) {
607
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
608
      final String markup = String.format( "%n%n%s ", hashes );
609
      final String text = get( "Main.menu.insert.header_" + i );
610
      final String accelerator = "Shortcut+" + i;
611
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
612
613
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
614
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
615
        activeFileEditorIsNull );
616
    }
617
618
    Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
619
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
620
      activeFileEditorIsNull );
621
    Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
622
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
623
      activeFileEditorIsNull );
624
    Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
625
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
626
      activeFileEditorIsNull );
627
628
    // Help actions
629
    Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
630
631
    //---- MenuBar ----
632
    Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
633
      fileNewAction,
634
      fileOpenAction,
635
      null,
636
      fileCloseAction,
637
      fileCloseAllAction,
638
      null,
639
      fileSaveAction,
640
      fileSaveAllAction,
641
      null,
642
      fileExitAction );
643
644
    Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
645
      editUndoAction,
646
      editRedoAction );
647
648
    Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
649
      insertBoldAction,
650
      insertItalicAction,
651
      insertSuperscriptAction,
652
      insertSubscriptAction,
653
      insertStrikethroughAction,
654
      insertBlockquoteAction,
655
      insertCodeAction,
656
      insertFencedCodeBlockAction,
657
      null,
658
      insertLinkAction,
659
      insertImageAction,
660
      null,
661
      headers[ 0 ],
662
      headers[ 1 ],
663
      headers[ 2 ],
664
      headers[ 3 ],
665
      headers[ 4 ],
666
      headers[ 5 ],
667
      null,
668
      insertUnorderedListAction,
669
      insertOrderedListAction,
670
      insertHorizontalRuleAction );
671
672
    Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
673
      helpAboutAction );
674
675
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
676
677
    //---- ToolBar ----
678
    ToolBar toolBar = ActionUtils.createToolBar(
679
      fileNewAction,
680
      fileOpenAction,
681
      fileSaveAction,
682
      null,
683
      editUndoAction,
684
      editRedoAction,
685
      null,
686
      insertBoldAction,
687
      insertItalicAction,
688
      insertSuperscriptAction,
689
      insertSubscriptAction,
690
      insertBlockquoteAction,
691
      insertCodeAction,
692
      insertFencedCodeBlockAction,
693
      null,
694
      insertLinkAction,
695
      insertImageAction,
696
      null,
697
      headers[ 0 ],
698
      null,
699
      insertUnorderedListAction,
700
      insertOrderedListAction );
701
702
    return new VBox( menuBar, toolBar );
703
  }
704
705
  /**
706
   * Creates a boolean property that is bound to another boolean value of the
707
   * active editor.
708
   */
709
  private BooleanProperty createActiveBooleanProperty(
710
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
711
712
    final BooleanProperty b = new SimpleBooleanProperty();
713
    final FileEditorTab tab = getActiveFileEditor();
714
715
    if( tab != null ) {
716
      b.bind( func.apply( tab ) );
717
    }
718
719
    getFileEditorPane().activeFileEditorProperty().addListener(
720
      (observable, oldFileEditor, newFileEditor) -> {
721
        b.unbind();
722
723
        if( newFileEditor != null ) {
724
          b.bind( func.apply( newFileEditor ) );
725
        } else {
726
          b.set( false );
727
        }
728
      }
729
    );
730
731
    return b;
732
  }
733
734
  private void initLayout() {
735
    final SplitPane splitPane = new SplitPane(
736
      getDefinitionPane().getNode(),
737
      getFileEditorPane().getNode(),
738
      getPreviewPane().getNode() );
739
740
    splitPane.setDividerPositions(
741
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
742
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
743
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
744
745
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
746
    final BorderPane borderPane = new BorderPane();
747
    borderPane.setPrefSize( 1024, 800 );
748
    borderPane.setTop( createMenuBar() );
41
import com.scrivenvar.service.events.Notifier;
42
import com.scrivenvar.util.Action;
43
import com.scrivenvar.util.ActionUtils;
44
import static com.scrivenvar.util.StageState.*;
45
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
46
import java.nio.file.Path;
47
import java.util.HashMap;
48
import java.util.Map;
49
import java.util.Observable;
50
import java.util.Observer;
51
import java.util.function.Function;
52
import java.util.prefs.Preferences;
53
import javafx.application.Platform;
54
import javafx.beans.binding.Bindings;
55
import javafx.beans.binding.BooleanBinding;
56
import javafx.beans.property.BooleanProperty;
57
import javafx.beans.property.SimpleBooleanProperty;
58
import javafx.beans.value.ObservableBooleanValue;
59
import javafx.beans.value.ObservableValue;
60
import javafx.collections.ListChangeListener.Change;
61
import javafx.collections.ObservableList;
62
import static javafx.event.Event.fireEvent;
63
import javafx.scene.Node;
64
import javafx.scene.Scene;
65
import javafx.scene.control.Alert;
66
import javafx.scene.control.Alert.AlertType;
67
import javafx.scene.control.Menu;
68
import javafx.scene.control.MenuBar;
69
import javafx.scene.control.SplitPane;
70
import javafx.scene.control.Tab;
71
import javafx.scene.control.ToolBar;
72
import javafx.scene.control.TreeView;
73
import javafx.scene.image.Image;
74
import javafx.scene.image.ImageView;
75
import static javafx.scene.input.KeyCode.ESCAPE;
76
import javafx.scene.input.KeyEvent;
77
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
78
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
79
import javafx.scene.layout.BorderPane;
80
import javafx.scene.layout.VBox;
81
import javafx.stage.Window;
82
import javafx.stage.WindowEvent;
83
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
84
import org.controlsfx.control.StatusBar;
85
86
/**
87
 * Main window containing a tab pane in the center for file editors.
88
 *
89
 * @author Karl Tauber and White Magic Software, Ltd.
90
 */
91
public class MainWindow implements Observer {
92
93
  private final Options options = Services.load( Options.class );
94
  private final Snitch snitch = Services.load( Snitch.class );
95
  private final Notifier notifier = Services.load( Notifier.class );
96
97
  private Scene scene;
98
  private MenuBar menuBar;
99
  private StatusBar statusBar;
100
101
  private DefinitionSource definitionSource;
102
  private DefinitionPane definitionPane;
103
  private FileEditorTabPane fileEditorPane;
104
  private HTMLPreviewPane previewPane;
105
106
  /**
107
   * Prevent re-instantiation processing classes.
108
   */
109
  private Map<FileEditorTab, Processor<String>> processors;
110
111
  public MainWindow() {
112
    initLayout();
113
    initDefinitionListener();
114
    initTabAddedListener();
115
    initTabChangedListener();
116
    initPreferences();
117
    initSnitch();
118
  }
119
120
  /**
121
   * Listen for file editor tab pane to receive an open definition source event.
122
   */
123
  private void initDefinitionListener() {
124
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
125
      (ObservableValue<? extends Path> definitionFile,
126
        final Path oldPath, final Path newPath) -> {
127
        openDefinition( newPath );
128
129
        // Indirectly refresh the resolved map.
130
        setProcessors( null );
131
132
        // Will create new processors and therefore a new resolved map.
133
        refreshSelectedTab( getActiveFileEditor() );
134
135
        updateDefinitionPane();
136
      }
137
    );
138
  }
139
140
  /**
141
   * When tabs are added, hook the various change listeners onto the new tab so
142
   * that the preview pane refreshes as necessary.
143
   */
144
  private void initTabAddedListener() {
145
    final FileEditorTabPane editorPane = getFileEditorPane();
146
147
    // Make sure the text processor kicks off when new files are opened.
148
    final ObservableList<Tab> tabs = editorPane.getTabs();
149
150
    // Update the preview pane on tab changes.
151
    tabs.addListener(
152
      (final Change<? extends Tab> change) -> {
153
        while( change.next() ) {
154
          if( change.wasAdded() ) {
155
            // Multiple tabs can be added simultaneously.
156
            for( final Tab newTab : change.getAddedSubList() ) {
157
              final FileEditorTab tab = (FileEditorTab)newTab;
158
159
              initTextChangeListener( tab );
160
              initCaretParagraphListener( tab );
161
              initVariableNameInjector( tab );
162
            }
163
          }
164
        }
165
      }
166
    );
167
  }
168
169
  /**
170
   * Reloads the preferences from the previous load.
171
   */
172
  private void initPreferences() {
173
    restoreDefinitionSource();
174
    getFileEditorPane().restorePreferences();
175
    updateDefinitionPane();
176
  }
177
178
  /**
179
   * Listen for new tab selection events.
180
   */
181
  private void initTabChangedListener() {
182
    final FileEditorTabPane editorPane = getFileEditorPane();
183
184
    // Update the preview pane changing tabs.
185
    editorPane.addTabSelectionListener(
186
      (ObservableValue<? extends Tab> tabPane,
187
        final Tab oldTab, final Tab newTab) -> {
188
189
        // If there was no old tab, then this is a first time load, which
190
        // can be ignored.
191
        if( oldTab != null ) {
192
          if( newTab == null ) {
193
            closeRemainingTab();
194
          } else {
195
            // Update the preview with the edited text.
196
            refreshSelectedTab( (FileEditorTab)newTab );
197
          }
198
        }
199
      }
200
    );
201
  }
202
203
  private void initTextChangeListener( final FileEditorTab tab ) {
204
    tab.addTextChangeListener(
205
      (ObservableValue<? extends String> editor,
206
        final String oldValue, final String newValue) -> {
207
        refreshSelectedTab( tab );
208
      }
209
    );
210
  }
211
212
  private void initCaretParagraphListener( final FileEditorTab tab ) {
213
    tab.addCaretParagraphListener(
214
      (ObservableValue<? extends Integer> editor,
215
        final Integer oldValue, final Integer newValue) -> {
216
        refreshSelectedTab( tab );
217
      }
218
    );
219
  }
220
221
  private void initVariableNameInjector( final FileEditorTab tab ) {
222
    VariableNameInjector.listen( tab, getDefinitionPane() );
223
  }
224
225
  /**
226
   * Watch for changes to external files. In particular, this awaits
227
   * modifications to any XSL files associated with XML files being edited. When
228
   * an XSL file is modified (external to the application), the snitch's ears
229
   * perk up and the file is reloaded. This keeps the XSL transformation up to
230
   * date with what's on the file system.
231
   */
232
  private void initSnitch() {
233
    getSnitch().addObserver( this );
234
  }
235
236
  /**
237
   * Called whenever the preview pane becomes out of sync with the file editor
238
   * tab. This can be called when the text changes, the caret paragraph changes,
239
   * or the file tab changes.
240
   *
241
   * @param tab The file editor tab that has been changed in some fashion.
242
   */
243
  private void refreshSelectedTab( final FileEditorTab tab ) {
244
    if( tab.isFileOpen() ) {
245
      getPreviewPane().setPath( tab.getPath() );
246
247
      Processor<String> processor = getProcessors().get( tab );
248
249
      if( processor == null ) {
250
        processor = createProcessor( tab );
251
        getProcessors().put( tab, processor );
252
      }
253
254
      try {
255
        processor.processChain( tab.getEditorText() );
256
        getNotifier().clear();
257
      } catch( final Exception ex ) {
258
        error( ex );
259
      }
260
    }
261
  }
262
263
  /**
264
   * Returns the variable map of interpolated definitions.
265
   *
266
   * @return A map to help dereference variables.
267
   */
268
  private Map<String, String> getResolvedMap() {
269
    return getDefinitionSource().getResolvedMap();
270
  }
271
272
  /**
273
   * Returns the root node for the hierarchical definition source.
274
   *
275
   * @return Data to display in the definition pane.
276
   */
277
  private TreeView<String> getTreeView() {
278
    try {
279
      return getDefinitionSource().asTreeView();
280
    } catch( Exception e ) {
281
      error( e );
282
    }
283
284
    return new TreeView<>();
285
  }
286
287
  /**
288
   * Called when a definition source is opened.
289
   *
290
   * @param path Path to the definition source that was opened.
291
   */
292
  private void openDefinition( final Path path ) {
293
    try {
294
      final DefinitionSource ds = createDefinitionSource( path.toString() );
295
      setDefinitionSource( ds );
296
      storeDefinitionSource();
297
      updateDefinitionPane();
298
    } catch( final Exception e ) {
299
      error( e );
300
    }
301
  }
302
303
  private void updateDefinitionPane() {
304
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
305
  }
306
307
  private void restoreDefinitionSource() {
308
    final Preferences preferences = getPreferences();
309
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
310
311
    // If there's no definition source set, don't try to load it.
312
    if( source != null ) {
313
      setDefinitionSource( createDefinitionSource( source ) );
314
    }
315
  }
316
317
  private void storeDefinitionSource() {
318
    final Preferences preferences = getPreferences();
319
    final DefinitionSource ds = getDefinitionSource();
320
321
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
322
  }
323
324
  /**
325
   * Called when the last open tab is closed to clear the preview pane.
326
   */
327
  private void closeRemainingTab() {
328
    getPreviewPane().clear();
329
  }
330
331
  /**
332
   * Called when an exception occurs that warrants the user's attention.
333
   *
334
   * @param e The exception with a message that the user should know about.
335
   */
336
  private void error( final Exception e ) {
337
    getNotifier().notify( e );
338
  }
339
340
  //---- File actions -------------------------------------------------------
341
  /**
342
   * Called when an observable instance has changed. This includes the snitch
343
   * service and the notify service.
344
   *
345
   * @param observable The observed instance.
346
   * @param o The noteworthy item.
347
   */
348
  @Override
349
  public void update( final Observable observable, final Object o ) {
350
    if( observable instanceof Snitch ) {
351
      if( o instanceof Path ) {
352
        update( (Path)o );
353
      }
354
    } else if( observable instanceof Notifier && o != null ) {
355
      final String s = (String)o;
356
      final int index = s.indexOf( '\n' );
357
      final String message = s.substring( 0, index > 0 ? index : s.length() );
358
359
      getStatusBar().setText( message );
360
    }
361
  }
362
363
  /**
364
   * Called when a file has been modified.
365
   *
366
   * @param file Path to the modified file.
367
   */
368
  private void update( final Path file ) {
369
    // Avoid throwing IllegalStateException by running from a non-JavaFX thread.
370
    Platform.runLater(
371
      () -> {
372
        // Brute-force XSLT file reload by re-instantiating all processors.
373
        resetProcessors();
374
        refreshSelectedTab( getActiveFileEditor() );
375
      }
376
    );
377
  }
378
379
  /**
380
   * After resetting the processors, they will refresh anew to be up-to-date
381
   * with the files (text and definition) currently loaded into the editor.
382
   */
383
  private void resetProcessors() {
384
    getProcessors().clear();
385
  }
386
387
  //---- File actions -------------------------------------------------------
388
  private void fileNew() {
389
    getFileEditorPane().newEditor();
390
  }
391
392
  private void fileOpen() {
393
    getFileEditorPane().openFileDialog();
394
  }
395
396
  private void fileClose() {
397
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
398
  }
399
400
  private void fileCloseAll() {
401
    getFileEditorPane().closeAllEditors();
402
  }
403
404
  private void fileSave() {
405
    getFileEditorPane().saveEditor( getActiveFileEditor() );
406
  }
407
408
  private void fileSaveAll() {
409
    getFileEditorPane().saveAllEditors();
410
  }
411
412
  private void fileExit() {
413
    final Window window = getWindow();
414
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
415
  }
416
417
  //---- Help actions -------------------------------------------------------
418
  private void helpAbout() {
419
    Alert alert = new Alert( AlertType.INFORMATION );
420
    alert.setTitle( get( "Dialog.about.title" ) );
421
    alert.setHeaderText( get( "Dialog.about.header" ) );
422
    alert.setContentText( get( "Dialog.about.content" ) );
423
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
424
    alert.initOwner( getWindow() );
425
426
    alert.showAndWait();
427
  }
428
429
  //---- Convenience accessors ----------------------------------------------
430
  private float getFloat( final String key, final float defaultValue ) {
431
    return getPreferences().getFloat( key, defaultValue );
432
  }
433
434
  private Preferences getPreferences() {
435
    return getOptions().getState();
436
  }
437
438
  public Window getWindow() {
439
    return getScene().getWindow();
440
  }
441
442
  private MarkdownEditorPane getActiveEditor() {
443
    final EditorPane pane = getActiveFileEditor().getEditorPane();
444
445
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
446
  }
447
448
  private FileEditorTab getActiveFileEditor() {
449
    return getFileEditorPane().getActiveFileEditor();
450
  }
451
452
  //---- Member accessors ---------------------------------------------------
453
  private void setScene( Scene scene ) {
454
    this.scene = scene;
455
  }
456
457
  public Scene getScene() {
458
    return this.scene;
459
  }
460
461
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
462
    this.processors = map;
463
  }
464
465
  private Map<FileEditorTab, Processor<String>> getProcessors() {
466
    if( this.processors == null ) {
467
      setProcessors( new HashMap<>() );
468
    }
469
470
    return this.processors;
471
  }
472
473
  private FileEditorTabPane getFileEditorPane() {
474
    if( this.fileEditorPane == null ) {
475
      this.fileEditorPane = createFileEditorPane();
476
    }
477
478
    return this.fileEditorPane;
479
  }
480
481
  private HTMLPreviewPane getPreviewPane() {
482
    if( this.previewPane == null ) {
483
      this.previewPane = createPreviewPane();
484
    }
485
486
    return this.previewPane;
487
  }
488
489
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
490
    this.definitionSource = definitionSource;
491
  }
492
493
  private DefinitionSource getDefinitionSource() {
494
    if( this.definitionSource == null ) {
495
      this.definitionSource = new EmptyDefinitionSource();
496
    }
497
498
    return this.definitionSource;
499
  }
500
501
  private DefinitionPane getDefinitionPane() {
502
    if( this.definitionPane == null ) {
503
      this.definitionPane = createDefinitionPane();
504
    }
505
506
    return this.definitionPane;
507
  }
508
509
  private Options getOptions() {
510
    return this.options;
511
  }
512
513
  private Snitch getSnitch() {
514
    return this.snitch;
515
  }
516
517
  private Notifier getNotifier() {
518
    return this.notifier;
519
  }
520
521
  public void setMenuBar( final MenuBar menuBar ) {
522
    this.menuBar = menuBar;
523
  }
524
525
  public MenuBar getMenuBar() {
526
    return this.menuBar;
527
  }
528
529
  private synchronized StatusBar getStatusBar() {
530
    if( this.statusBar == null ) {
531
      this.statusBar = createStatusBar();
532
    }
533
534
    return this.statusBar;
535
  }
536
537
  //---- Member creators ----------------------------------------------------
538
  /**
539
   * Factory to create processors that are suited to different file types.
540
   *
541
   * @param tab The tab that is subjected to processing.
542
   *
543
   * @return A processor suited to the file type specified by the tab's path.
544
   */
545
  private Processor<String> createProcessor( final FileEditorTab tab ) {
546
    return createProcessorFactory().createProcessor( tab );
547
  }
548
549
  private ProcessorFactory createProcessorFactory() {
550
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
551
  }
552
553
  private DefinitionSource createDefinitionSource( final String path ) {
554
    return createDefinitionFactory().createDefinitionSource( path );
555
  }
556
557
  /**
558
   * Create an editor pane to hold file editor tabs.
559
   *
560
   * @return A new instance, never null.
561
   */
562
  private FileEditorTabPane createFileEditorPane() {
563
    return new FileEditorTabPane();
564
  }
565
566
  private HTMLPreviewPane createPreviewPane() {
567
    return new HTMLPreviewPane();
568
  }
569
570
  private DefinitionPane createDefinitionPane() {
571
    return new DefinitionPane( getTreeView() );
572
  }
573
574
  private DefinitionFactory createDefinitionFactory() {
575
    return new DefinitionFactory();
576
  }
577
578
  private StatusBar createStatusBar() {
579
    return new StatusBar();
580
  }
581
582
  private Node createMenuBar() {
583
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
584
585
    // File actions
586
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
587
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
588
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
589
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
590
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
591
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
592
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
593
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
594
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
595
596
    // Edit actions
597
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
598
      e -> getActiveEditor().undo(),
599
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
600
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
601
      e -> getActiveEditor().redo(),
602
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
603
    Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Shortcut+F", SEARCH,
604
      e -> getActiveEditor().find(),
605
      activeFileEditorIsNull );
606
    Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
607
      e -> getActiveEditor().replace(),
608
      activeFileEditorIsNull );
609
    Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
610
      e -> getActiveEditor().findNext(),
611
      activeFileEditorIsNull );
612
    Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
613
      e -> getActiveEditor().findPrevious(),
614
      activeFileEditorIsNull );
615
616
    // Insert actions
617
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
618
      e -> getActiveEditor().surroundSelection( "**", "**" ),
619
      activeFileEditorIsNull );
620
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
621
      e -> getActiveEditor().surroundSelection( "*", "*" ),
622
      activeFileEditorIsNull );
623
    Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
624
      e -> getActiveEditor().surroundSelection( "^", "^" ),
625
      activeFileEditorIsNull );
626
    Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
627
      e -> getActiveEditor().surroundSelection( "~", "~" ),
628
      activeFileEditorIsNull );
629
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
630
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
631
      activeFileEditorIsNull );
632
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
633
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
634
      activeFileEditorIsNull );
635
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
636
      e -> getActiveEditor().surroundSelection( "`", "`" ),
637
      activeFileEditorIsNull );
638
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
639
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
640
      activeFileEditorIsNull );
641
642
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
643
      e -> getActiveEditor().insertLink(),
644
      activeFileEditorIsNull );
645
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
646
      e -> getActiveEditor().insertImage(),
647
      activeFileEditorIsNull );
648
649
    final Action[] headers = new Action[ 6 ];
650
651
    // Insert header actions (H1 ... H6)
652
    for( int i = 1; i <= 6; i++ ) {
653
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
654
      final String markup = String.format( "%n%n%s ", hashes );
655
      final String text = get( "Main.menu.insert.header_" + i );
656
      final String accelerator = "Shortcut+" + i;
657
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
658
659
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
660
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
661
        activeFileEditorIsNull );
662
    }
663
664
    Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
665
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
666
      activeFileEditorIsNull );
667
    Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
668
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
669
      activeFileEditorIsNull );
670
    Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
671
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
672
      activeFileEditorIsNull );
673
674
    // Help actions
675
    Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
676
677
    //---- MenuBar ----
678
    Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
679
      fileNewAction,
680
      fileOpenAction,
681
      null,
682
      fileCloseAction,
683
      fileCloseAllAction,
684
      null,
685
      fileSaveAction,
686
      fileSaveAllAction,
687
      null,
688
      fileExitAction );
689
690
    Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
691
      editUndoAction,
692
      editRedoAction,
693
      editFindAction,
694
      editReplaceAction,
695
      editFindNextAction,
696
      editFindPreviousAction );
697
698
    Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
699
      insertBoldAction,
700
      insertItalicAction,
701
      insertSuperscriptAction,
702
      insertSubscriptAction,
703
      insertStrikethroughAction,
704
      insertBlockquoteAction,
705
      insertCodeAction,
706
      insertFencedCodeBlockAction,
707
      null,
708
      insertLinkAction,
709
      insertImageAction,
710
      null,
711
      headers[ 0 ],
712
      headers[ 1 ],
713
      headers[ 2 ],
714
      headers[ 3 ],
715
      headers[ 4 ],
716
      headers[ 5 ],
717
      null,
718
      insertUnorderedListAction,
719
      insertOrderedListAction,
720
      insertHorizontalRuleAction );
721
722
    Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
723
      helpAboutAction );
724
725
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
726
727
    //---- ToolBar ----
728
    ToolBar toolBar = ActionUtils.createToolBar(
729
      fileNewAction,
730
      fileOpenAction,
731
      fileSaveAction,
732
      null,
733
      editUndoAction,
734
      editRedoAction,
735
      null,
736
      insertBoldAction,
737
      insertItalicAction,
738
      insertSuperscriptAction,
739
      insertSubscriptAction,
740
      insertBlockquoteAction,
741
      insertCodeAction,
742
      insertFencedCodeBlockAction,
743
      null,
744
      insertLinkAction,
745
      insertImageAction,
746
      null,
747
      headers[ 0 ],
748
      null,
749
      insertUnorderedListAction,
750
      insertOrderedListAction );
751
752
    return new VBox( menuBar, toolBar );
753
  }
754
755
  /**
756
   * Creates a boolean property that is bound to another boolean value of the
757
   * active editor.
758
   */
759
  private BooleanProperty createActiveBooleanProperty(
760
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
761
762
    final BooleanProperty b = new SimpleBooleanProperty();
763
    final FileEditorTab tab = getActiveFileEditor();
764
765
    if( tab != null ) {
766
      b.bind( func.apply( tab ) );
767
    }
768
769
    getFileEditorPane().activeFileEditorProperty().addListener(
770
      (observable, oldFileEditor, newFileEditor) -> {
771
        b.unbind();
772
773
        if( newFileEditor != null ) {
774
          b.bind( func.apply( newFileEditor ) );
775
        } else {
776
          b.set( false );
777
        }
778
      }
779
    );
780
781
    return b;
782
  }
783
784
  private void initLayout() {
785
    final SplitPane splitPane = new SplitPane(
786
      getDefinitionPane().getNode(),
787
      getFileEditorPane().getNode(),
788
      getPreviewPane().getNode() );
789
790
    splitPane.setDividerPositions(
791
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
792
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
793
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
794
795
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
796
    final BorderPane borderPane = new BorderPane();
797
    borderPane.setPrefSize( 1024, 800 );
798
    borderPane.setTop( createMenuBar() );
799
    borderPane.setBottom( getStatusBar() );
749800
    borderPane.setCenter( splitPane );
750801
M src/main/java/com/scrivenvar/Messages.java
2727
package com.scrivenvar;
2828
29
import static com.scrivenvar.Constants.APP_BUNDLE_NAME;
2930
import java.text.MessageFormat;
3031
import java.util.ResourceBundle;
3132
import java.util.Stack;
32
import static com.scrivenvar.Constants.APP_BUNDLE_NAME;
3333
3434
/**
...
113113
    try {
114114
      result = resolve( RESOURCE_BUNDLE, RESOURCE_BUNDLE.getString( key ) );
115
    } catch( Exception e ) {
116
      
117
      // Instead of crashing, launch the application and show the resource
118
      // name.
115
    } catch( final Exception ex ) {
119116
      result = key;
120117
    }
M src/main/java/com/scrivenvar/definition/DefinitionFactory.java
152152
    try {
153153
      result = file.toURI().toURL().getProtocol();
154
    } catch( Exception e ) {
154
    } catch( final Exception e ) {
155155
      result = DEFINITION_PROTOCOL_UNKNOWN;
156156
    }
M src/main/java/com/scrivenvar/definition/yaml/YamlFileDefinitionSource.java
2828
package com.scrivenvar.definition.yaml;
2929
30
import com.scrivenvar.definition.FileDefinitionSource;
3130
import static com.scrivenvar.Messages.get;
31
import com.scrivenvar.definition.FileDefinitionSource;
3232
import java.io.InputStream;
3333
import java.nio.file.Files;
...
107107
    try( final InputStream in = Files.newInputStream( getPath() ) ) {
108108
      return new YamlParser( in );
109
    } catch( final Exception e ) {
110
      throw new RuntimeException( e );
109
    } catch( final Exception ex ) {
110
      throw new RuntimeException( ex );
111111
    }
112112
  }
M src/main/java/com/scrivenvar/editors/EditorPane.java
4343
import org.fxmisc.wellbehaved.event.EventPattern;
4444
import org.fxmisc.wellbehaved.event.InputMap;
45
import static org.fxmisc.wellbehaved.event.InputMap.consume;
4645
import org.fxmisc.wellbehaved.event.Nodes;
46
import static org.fxmisc.wellbehaved.event.InputMap.consume;
4747
4848
/**
...
7373
  public void redo() {
7474
    getUndoManager().redo();
75
  }
76
  
77
  public void find() {
78
    System.out.println( "search" );
79
  }
80
  
81
  public void replace() {
82
    System.out.println( "replace" );
83
  }
84
  
85
  public void findNext() {
86
    System.out.println( "find next" );
87
  }
88
89
  public void findPrevious() {
90
    System.out.println( "find previous" );
7591
  }
7692
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
6464
import static org.fxmisc.wellbehaved.event.InputMap.consume;
6565
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
66
import static com.scrivenvar.util.Lists.getFirst;
67
import static com.scrivenvar.util.Lists.getLast;
68
import static java.lang.Character.isSpaceChar;
69
import static java.lang.Character.isWhitespace;
70
import static java.lang.Math.min;
71
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
72
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
73
import static org.fxmisc.wellbehaved.event.InputMap.consume;
6674
6775
/**
M src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
6363
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
6464
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
65
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
66
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
67
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
68
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
69
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
70
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
71
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
72
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
73
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
74
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
75
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
76
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
77
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
78
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
79
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
80
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
6581
6682
/**
M src/main/java/com/scrivenvar/preferences/FilePreferences.java
6363
      sync();
6464
    } catch( final BackingStoreException ex ) {
65
      problem( ex );
65
      error( ex );
6666
    }
6767
  }
...
7474
      flush();
7575
    } catch( final BackingStoreException ex ) {
76
      problem( ex );
76
      error( ex );
7777
    }
7878
  }
...
9090
      flush();
9191
    } catch( final BackingStoreException ex ) {
92
      problem( ex );
92
      error( ex );
9393
    }
9494
  }
...
155155
          }
156156
        }
157
      } catch( final IOException e ) {
158
        throw new BackingStoreException( e );
157
      } catch( final IOException ex ) {
158
        error( new BackingStoreException( ex ) );
159159
      }
160160
    }
...
211211
212212
        p.store( new FileOutputStream( file ), "FilePreferences" );
213
      } catch( final IOException e ) {
214
        throw new BackingStoreException( e );
213
      } catch( final IOException ex ) {
214
        error( new BackingStoreException( ex ) );
215215
      }
216216
    }
217217
  }
218218
219
  private void problem( final BackingStoreException ex ) {
219
  private void error( final BackingStoreException ex ) {
220220
    throw new RuntimeException( ex );
221221
  }
M src/main/java/com/scrivenvar/processors/CaretInsertionProcessor.java
6565
  protected String inject( final String text, final int i ) {
6666
    if( i > 0 && i <= text.length() ) {
67
      // Preserve the newline character when inserting the caret position mark.
6768
      final String replacement = text.charAt( i - 1 ) == NEWLINE
6869
        ? NEWLINE_CARET_POSITION_MD
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
107107
108108
      } else {
109
        // TODO: Implement this.
109110
        // There was a starting prefix but no ending suffix. Ignore the
110111
        // problem, copy to the end, and exit the loop.
111
//        sb.append()
112
112
        //sb.append()
113113
      }
114114
...
134134
      return getScriptEngine().eval( r );
135135
    } catch( final ScriptException ex ) {
136
      problem( ex );
136
      throw new IllegalArgumentException( ex );
137137
    }
138
139
    return "";
140138
  }
141139
142140
  private synchronized ScriptEngine getScriptEngine() {
143141
    if( this.engine == null ) {
144142
      this.engine = (new ScriptEngineManager()).getEngineByName( "Renjin" );
145143
    }
146144
147145
    return this.engine;
148
  }
149
150
  /**
151
   * Notify the user (passively) of the problem.
152
   *
153
   * @param ex A problem parsing the text.
154
   */
155
  private void problem( final Exception ex ) {
156
    // TODO: Use the notify service to warn the user that there's an issue.
157
    System.out.println( ex );
158146
  }
159147
}
M src/main/java/com/scrivenvar/processors/RMarkdownCaretInsertionProcessor.java
6767
6868
    // Search for inline R code from the start of the caret's paragraph.
69
    // This should be much faster than scanning text from the beginning.
6970
    int index = text.lastIndexOf( NEWLINE, offset );
7071
...
8687
      // beyond the caret position.
8788
      while( index != INDEX_NOT_FOUND && index < offset ) {
88
        // Set rPrefix to the index that might precede the caret.
89
        // Set rPrefix to the index that might precede the caret. The + 1 is
90
        // to skip passed the leading backtick in the prefix (`r#).
8991
        rPrefix = index + 1;
9092
...
9799
      final int rSuffix = max( text.indexOf( SUFFIX, rPrefix ), rPrefix );
98100
101
      // If the caret falls between the rPrefix and rSuffix, then change the
102
      // insertion point.
99103
      final boolean between = isBetween( offset, rPrefix, rSuffix );
100104
      
M src/main/java/com/scrivenvar/processors/XMLProcessor.java
9898
    try {
9999
      return text.isEmpty() ? text : transform( text );
100
    } catch( Exception e ) {
101
      throw new RuntimeException( e );
100
    } catch( final Exception ex ) {
101
      throw new RuntimeException( ex );
102102
    }
103103
  }
A src/main/java/com/scrivenvar/service/events/Notifier.java
1
/*
2
 * Copyright 2016 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.service.events;
29
30
import java.util.Observer;
31
import javafx.scene.control.Alert;
32
import javafx.scene.control.ButtonType;
33
import javafx.stage.Window;
34
35
/**
36
 * Provides the application with a uniform way to notify the user of events.
37
 *
38
 * @author White Magic Software, Ltd.
39
 */
40
public interface Notifier {
41
42
  public static final ButtonType YES = ButtonType.YES;
43
  public static final ButtonType NO = ButtonType.NO;
44
  public static final ButtonType CANCEL = ButtonType.CANCEL;
45
46
  /**
47
   * Notifies the user of a problem.
48
   *
49
   * @param message The problem description.
50
   */
51
  public void notify( final String message );
52
53
  /**
54
   * Notifies the user about the exception.
55
   *
56
   * @param exception The exception containing a message to show to the user.
57
   */
58
  default public void notify( final Exception exception ) {
59
    notify( exception.getMessage() );
60
  }
61
62
  /**
63
   * Causes any displayed notifications to disappear.
64
   */
65
  public void clear();
66
67
  /**
68
   * Constructs a default alert message text for a modal alert dialog.
69
   *
70
   * @param title The dialog box message title.
71
   * @param message The dialog box message content (needs formatting).
72
   * @param args The arguments to the message content that must be formatted.
73
   *
74
   * @return The message suitable for building a modal alert dialog.
75
   */
76
  public Notification createNotification(
77
    String title,
78
    String message,
79
    Object... args );
80
81
  /**
82
   * Creates an alert of alert type error with a message showing the cause of
83
   * the error.
84
   *
85
   * @param parent Dialog box owner (for modal purposes).
86
   * @param message The error message, title, and possibly more details.
87
   *
88
   * @return A modal alert dialog box ready to display using showAndWait.
89
   */
90
  public Alert createError( Window parent, Notification message );
91
92
  /**
93
   * Creates an alert of alert type confirmation with Yes/No/Cancel buttons.
94
   *
95
   * @param parent Dialog box owner (for modal purposes).
96
   * @param message The message, title, and possibly more details.
97
   *
98
   * @return A modal alert dialog box ready to display using showAndWait.
99
   */
100
  public Alert createConfirmation( Window parent, Notification message );
101
102
  /**
103
   * Adds an observer to the list of objects that receive notifications about
104
   * error messages to be presented to the user.
105
   *
106
   * @param observer The observer instance to notify.
107
   */
108
  public void addObserver( Observer observer );
109
110
  /**
111
   * Removes an observer from the list of objects that receive notifications
112
   * about error messages to be presented to the user.
113
   *
114
   * @param observer The observer instance to no longer notify.
115
   */
116
  public void deleteObserver( Observer observer );
117
}
1118
D src/main/java/com/scrivenvar/service/events/NotifyService.java
1
/*
2
 * Copyright 2016 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.service.events;
29
30
import javafx.scene.control.Alert;
31
import javafx.scene.control.ButtonType;
32
import javafx.stage.Window;
33
34
/**
35
 * Provides the application with a uniform way to notify the user of events.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public interface NotifyService {
40
  public static final ButtonType YES = ButtonType.YES;
41
  public static final ButtonType NO = ButtonType.NO;
42
  public static final ButtonType CANCEL = ButtonType.CANCEL;
43
44
  /**
45
   * Called to set the window used as the parent for the alert dialogs.
46
   *
47
   * @param window
48
   */
49
  public void setWindow( Window window );
50
51
  /**
52
   * Constructs a default alert message text for a modal alert dialog.
53
   *
54
   * @param title The dialog box message title.
55
   * @param message The dialog box message content (needs formatting).
56
   * @param args The arguments to the message content that must be formatted.
57
   *
58
   * @return The message suitable for building a modal alert dialog.
59
   */
60
  public Notification createNotification(
61
    String title,
62
    String message,
63
    Object... args );
64
65
  /**
66
   * Creates an alert of alert type error with a message showing the cause of
67
   * the error.
68
   *
69
   * @param message The error message, title, and possibly more details.
70
   *
71
   * @return A modal alert dialog box ready to display using showAndWait.
72
   */
73
  public Alert createError( Notification message );
74
75
  /**
76
   * Creates an alert of alert type confirmation with Yes/No/Cancel buttons.
77
   *
78
   * @param message The message, title, and possibly more details.
79
   *
80
   * @return A modal alert dialog box ready to display using showAndWait.
81
   */
82
  public Alert createConfirmation( Notification message );
83
}
841
A src/main/java/com/scrivenvar/service/events/impl/DefaultNotifier.java
1
/*
2
 * Copyright 2016 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.service.events.impl;
29
30
import com.scrivenvar.service.events.Notification;
31
import com.scrivenvar.service.events.Notifier;
32
import java.util.Observable;
33
import javafx.scene.control.Alert;
34
import javafx.scene.control.Alert.AlertType;
35
import static javafx.scene.control.Alert.AlertType.CONFIRMATION;
36
import static javafx.scene.control.Alert.AlertType.ERROR;
37
import javafx.stage.Window;
38
39
/**
40
 * Provides the ability to notify the user of problems.
41
 *
42
 * @author White Magic Software, Ltd.
43
 */
44
public final class DefaultNotifier extends Observable
45
  implements Notifier {
46
47
  public DefaultNotifier() {
48
  }
49
50
  /**
51
   * Notifies all observer instances of the given message.
52
   *
53
   * @param message The text to display to the user.
54
   */
55
  @Override
56
  public void notify( final String message ) {
57
    setChanged();
58
    notifyObservers( message );
59
  }
60
61
  /**
62
   * Contains all the information that the user needs to know about a problem.
63
   *
64
   * @param title The context for the message.
65
   * @param message The message content (formatted with the given args).
66
   * @param args Parameters for the message content.
67
   *
68
   * @return
69
   */
70
  @Override
71
  public Notification createNotification(
72
    final String title,
73
    final String message,
74
    final Object... args ) {
75
    return new DefaultNotification( title, message, args );
76
  }
77
78
  @Override
79
  public void clear() {
80
    setChanged();
81
    notifyObservers( "OK" );
82
  }
83
84
  private Alert createAlertDialog(
85
    final Window parent,
86
    final AlertType alertType,
87
    final Notification message ) {
88
89
    final Alert alert = new Alert( alertType );
90
91
    alert.setDialogPane( new ButtonOrderPane() );
92
    alert.setTitle( message.getTitle() );
93
    alert.setHeaderText( null );
94
    alert.setContentText( message.getContent() );
95
    alert.initOwner( parent );
96
97
    return alert;
98
  }
99
100
  @Override
101
  public Alert createConfirmation( final Window parent, final Notification message ) {
102
    final Alert alert = createAlertDialog( parent, CONFIRMATION, message );
103
104
    alert.getButtonTypes().setAll( YES, NO, CANCEL );
105
106
    return alert;
107
  }
108
109
  @Override
110
  public Alert createError( final Window parent, final Notification message ) {
111
    return createAlertDialog( parent, ERROR, message );
112
  }
113
}
1114
D src/main/java/com/scrivenvar/service/events/impl/DefaultNotifyService.java
1
/*
2
 * Copyright 2016 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.service.events.impl;
29
30
import com.scrivenvar.service.events.NotifyService;
31
import javafx.scene.control.Alert;
32
import javafx.scene.control.Alert.AlertType;
33
import static javafx.scene.control.Alert.AlertType.CONFIRMATION;
34
import static javafx.scene.control.Alert.AlertType.ERROR;
35
import javafx.stage.Window;
36
import com.scrivenvar.service.events.Notification;
37
38
/**
39
 * Provides the ability to notify the user of problems.
40
 *
41
 * @author White Magic Software, Ltd.
42
 */
43
public final class DefaultNotifyService implements NotifyService {
44
45
  private Window window;
46
47
  public DefaultNotifyService() {
48
  }
49
50
  public DefaultNotifyService( final Window window ) {
51
    this.window = window;
52
  }
53
54
  /**
55
   * Contains all the information that the user needs to know about a problem.
56
   * 
57
   * @param title The context for the message.
58
   * @param message The message content (formatted with the given args).
59
   * @param args Parameters for the message content.
60
   * @return 
61
   */
62
  @Override
63
  public Notification createNotification(
64
    final String title,
65
    final String message,
66
    final Object... args ) {
67
    return new DefaultNotification( title, message, args );
68
  }
69
70
  private Alert createAlertDialog(
71
    final AlertType alertType,
72
    final Notification message ) {
73
74
    final Alert alert = new Alert( alertType );
75
76
    alert.setDialogPane( new ButtonOrderPane() );
77
    alert.setTitle( message.getTitle() );
78
    alert.setHeaderText( null );
79
    alert.setContentText( message.getContent() );
80
    alert.initOwner( getWindow() );
81
82
    return alert;
83
  }
84
85
  @Override
86
  public Alert createConfirmation( final Notification message ) {
87
    final Alert alert = createAlertDialog( CONFIRMATION, message );
88
89
    alert.getButtonTypes().setAll( YES, NO, CANCEL );
90
91
    return alert;
92
  }
93
94
  @Override
95
  public Alert createError( final Notification message ) {
96
    return createAlertDialog( ERROR, message );
97
  }
98
99
  private Window getWindow() {
100
    return this.window;
101
  }
102
103
  @Override
104
  public void setWindow( Window window ) {
105
    this.window = window;
106
  }
107
}
1081
M src/main/java/com/scrivenvar/service/impl/DefaultSettings.java
133133
        configuration.read( r );
134134
135
      } catch( IOException e ) {
136
        throw new ConfigurationException( e );
135
      } catch( final IOException ex ) {
136
        throw new RuntimeException( new ConfigurationException( ex ) );
137137
      }
138138
    }
M src/main/java/com/scrivenvar/service/impl/DefaultSnitch.java
163163
          ignore( path );
164164
        }
165
      } catch( IOException | InterruptedException ex ) {
165
      } catch( final IOException | InterruptedException ex ) {
166166
        // Stop eavesdropping.
167167
        setListening( false );
A src/main/resources/META-INF/services/com.scrivenvar.service.events.Notifier
1
1
com.scrivenvar.service.events.impl.DefaultNotifier
D src/main/resources/META-INF/services/com.scrivenvar.service.events.NotifyService
1
com.scrivenvar.service.events.impl.DefaultNotifyService
1
M src/main/resources/com/scrivenvar/messages.properties
4848
4949
Main.menu.edit=_Edit
50
Main.menu.edit.undo=Undo
51
Main.menu.edit.redo=Redo
50
Main.menu.edit.undo=_Undo
51
Main.menu.edit.redo=_Redo
52
Main.menu.edit.find=_Find
53
Main.menu.edit.find.replace=Re_place
54
Main.menu.edit.find.next=Find _Next
55
Main.menu.edit.find.previous=Find _Previous
5256
5357
Main.menu.insert=_Insert