Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M README.md
3232
Usage
3333
---
34
1. [Download](https://github.com/DaveJarvis/scrivenvar/releases) the jar file.
34
1. [Download](https://github.com/DaveJarvis/scrivenvar/releases) `scrivenvar.jar`.
3535
1. Double-click `scrivenvar.jar` to start the application.
3636
M build.gradle
3333
}
3434
35
version = '1.1.6'
35
version = '1.2.0'
3636
applicationName = 'scrivenvar'
3737
mainClassName = 'com.scrivenvar.Main'
3838
sourceCompatibility = JavaVersion.VERSION_1_8
3939
4040
jar {
4141
  baseName = applicationName
42
  archiveName = "${applicationName}.jar"
4243
  
4344
  doFirst {
M src/main/java/com/scrivenvar/FileEditorTab.java
3131
import com.scrivenvar.service.events.Notifier;
3232
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 static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
54
import org.fxmisc.richtext.model.TwoDimensional.Position;
55
import org.fxmisc.undo.UndoManager;
56
import org.fxmisc.wellbehaved.event.EventPattern;
57
import org.fxmisc.wellbehaved.event.InputMap;
58
import org.mozilla.universalchardet.UniversalDetector;
59
60
/**
61
 * Editor for a single file.
62
 *
63
 * @author Karl Tauber and White Magic Software, Ltd.
64
 */
65
public final class FileEditorTab extends Tab {
66
67
  private final Notifier alertService = Services.load( Notifier.class );
68
  private EditorPane editorPane;
69
70
  /**
71
   * Character encoding used by the file (or default encoding if none found).
72
   */
73
  private Charset encoding;
74
75
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
76
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
77
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
78
79
  // Might be simpler to revert this back to a property and have the main
80
  // window listen for changes to it...
81
  private Path path;
82
83
  FileEditorTab( final Path path ) {
84
    setPath( path );
85
86
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
87
    updateTab();
88
89
    setOnSelectionChanged( e -> {
90
      if( isSelected() ) {
91
        Platform.runLater( () -> activated() );
92
      }
93
    } );
94
  }
95
96
  private void updateTab() {
97
    setText( getTabTitle() );
98
    setGraphic( getModifiedMark() );
99
    setTooltip( getTabTooltip() );
100
  }
101
102
  /**
103
   * Returns the base filename (without the directory names).
104
   *
105
   * @return The untitled text if the path hasn't been set.
106
   */
107
  private String getTabTitle() {
108
    final Path filePath = getPath();
109
110
    return (filePath == null)
111
      ? Messages.get( "FileEditor.untitled" )
112
      : filePath.getFileName().toString();
113
  }
114
115
  /**
116
   * Returns the full filename represented by the path.
117
   *
118
   * @return The untitled text if the path hasn't been set.
119
   */
120
  private Tooltip getTabTooltip() {
121
    final Path filePath = getPath();
122
    return new Tooltip( filePath == null ? "" : filePath.toString() );
123
  }
124
125
  /**
126
   * Returns a marker to indicate whether the file has been modified.
127
   *
128
   * @return "*" when the file has changed; otherwise null.
129
   */
130
  private Text getModifiedMark() {
131
    return isModified() ? new Text( "*" ) : null;
132
  }
133
134
  /**
135
   * Called when the user switches tab.
136
   */
137
  private void activated() {
138
    // Tab is closed or no longer active.
139
    if( getTabPane() == null || !isSelected() ) {
140
      return;
141
    }
142
143
    // Switch to the tab without loading if the contents are already in memory.
144
    if( getContent() != null ) {
145
      getEditorPane().requestFocus();
146
      return;
147
    }
148
149
    // Load the text and update the preview before the undo manager.
150
    load();
151
152
    // Track undo requests -- can only be called *after* load.
153
    initUndoManager();
154
    initLayout();
155
    initFocus();
156
  }
157
158
  private void initLayout() {
159
    setContent( getScrollPane() );
160
  }
161
162
  private Node getScrollPane() {
163
    return getEditorPane().getScrollPane();
164
  }
165
166
  private void initFocus() {
167
    getEditorPane().requestFocus();
168
  }
169
170
  private void initUndoManager() {
171
    final UndoManager undoManager = getUndoManager();
172
173
    // Clear undo history after first load.
174
    undoManager.forgetHistory();
175
176
    // Bind the editor undo manager to the properties.
177
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
178
    canUndo.bind( undoManager.undoAvailableProperty() );
179
    canRedo.bind( undoManager.redoAvailableProperty() );
180
  }
181
182
  /**
183
   * Searches from the caret position forward for the given string.
184
   *
185
   * @param needle The text string to match.
186
   */
187
  public void searchNext( final String needle ) {
188
    final String haystack = getEditorText();
189
    int index = haystack.indexOf( needle, getCaretPosition() );
190
191
    // Wrap around.
192
    if( index == -1 ) {
193
      index = haystack.indexOf( needle, 0 );
194
    }
195
196
    if( index >= 0 ) {
197
      setCaretPosition( index );
198
      getEditor().selectRange( index, index + needle.length() );
199
    }
200
  }
201
202
  /**
203
   * Returns the index into the text where the caret blinks happily away.
204
   *
205
   * @return A number from 0 to the editor's document text length.
206
   */
207
  public int getCaretPosition() {
208
    return getEditor().getCaretPosition();
209
  }
210
211
  /**
212
   * Moves the caret to a given offset.
213
   *
214
   * @param offset The new caret offset.
215
   */
216
  private void setCaretPosition( final int offset ) {
217
    getEditor().moveTo( offset );
218
    getEditor().requestFollowCaret();
219
  }
220
221
  /**
222
   * Returns the caret's current row and column position.
223
   *
224
   * @return The caret's offset into the document.
225
   */
226
  public Position getCaretOffset() {
227
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
228
  }
229
230
  /**
231
   * Allows observers to synchronize caret position changes.
232
   *
233
   * @return An observable caret property value.
234
   */
235
  public final ObservableValue<Integer> caretPositionProperty() {
236
    return getEditor().caretPositionProperty();
237
  }
238
239
  /**
240
   * Returns the text area associated with this tab.
241
   *
242
   * @return A text editor.
243
   */
244
  private StyleClassedTextArea getEditor() {
245
    return getEditorPane().getEditor();
246
  }
247
248
  /**
249
   * Returns true if the given path exactly matches this tab's path.
250
   *
251
   * @param check The path to compare against.
252
   *
253
   * @return true The paths are the same.
254
   */
255
  public boolean isPath( final Path check ) {
256
    final Path filePath = getPath();
257
258
    return filePath == null ? false : filePath.equals( check );
259
  }
260
261
  /**
262
   * Reads the entire file contents from the path associated with this tab.
263
   */
264
  private void load() {
265
    final Path filePath = getPath();
266
267
    if( filePath != null ) {
268
      try {
269
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
270
      } catch( final Exception ex ) {
271
        getNotifyService().notify( ex );
272
      }
273
    }
274
  }
275
276
  /**
277
   * Saves the entire file contents from the path associated with this tab.
278
   *
279
   * @return true The file has been saved.
280
   */
281
  public boolean save() {
282
    try {
283
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
284
      getEditorPane().getUndoManager().mark();
285
      return true;
286
    } catch( final Exception ex ) {
287
      return alert(
288
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
289
      );
290
    }
291
  }
292
293
  /**
294
   * Creates an alert dialog and waits for it to close.
295
   *
296
   * @param titleKey Resource bundle key for the alert dialog title.
297
   * @param messageKey Resource bundle key for the alert dialog message.
298
   * @param e The unexpected happening.
299
   *
300
   * @return false
301
   */
302
  private boolean alert(
303
    final String titleKey, final String messageKey, final Exception e ) {
304
    final Notifier service = getNotifyService();
305
    final Path filePath = getPath();
306
307
    final Notification message = service.createNotification(
308
      Messages.get( titleKey ),
309
      Messages.get( messageKey ),
310
      filePath == null ? "" : filePath,
311
      e.getMessage()
312
    );
313
314
    service.createError( getWindow(), message ).showAndWait();
315
    return false;
316
  }
317
318
  private Window getWindow() {
319
    return getEditorPane().getScene().getWindow();
320
  }
321
322
  /**
323
   * Returns a best guess at the file encoding. If the encoding could not be
324
   * detected, this will return the default charset for the JVM.
325
   *
326
   * @param bytes The bytes to perform character encoding detection.
327
   *
328
   * @return The character encoding.
329
   */
330
  private Charset detectEncoding( final byte[] bytes ) {
331
    final UniversalDetector detector = new UniversalDetector( null );
332
    detector.handleData( bytes, 0, bytes.length );
333
    detector.dataEnd();
334
335
    final String charset = detector.getDetectedCharset();
336
    final Charset charEncoding = charset == null
337
      ? Charset.defaultCharset()
338
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
339
340
    detector.reset();
341
342
    return charEncoding;
343
  }
344
345
  /**
346
   * Converts the given string to an array of bytes using the encoding that was
347
   * originally detected (if any) and associated with this file.
348
   *
349
   * @param text The text to convert into the original file encoding.
350
   *
351
   * @return A series of bytes ready for writing to a file.
352
   */
353
  private byte[] asBytes( final String text ) {
354
    return text.getBytes( getEncoding() );
355
  }
356
357
  /**
358
   * Converts the given bytes into a Java String. This will call setEncoding
359
   * with the encoding detected by the CharsetDetector.
360
   *
361
   * @param text The text of unknown character encoding.
362
   *
363
   * @return The text, in its auto-detected encoding, as a String.
364
   */
365
  private String asString( final byte[] text ) {
366
    setEncoding( detectEncoding( text ) );
367
    return new String( text, getEncoding() );
368
  }
369
370
  public Path getPath() {
371
    return this.path;
372
  }
373
374
  public void setPath( final Path path ) {
375
    this.path = path;
376
  }
377
378
  /**
379
   * Answers whether this tab has an initialized path reference.
380
   *
381
   * @return false This tab has no path.
382
   */
383
  public boolean isFileOpen() {
384
    return this.path != null;
385
  }
386
387
  public boolean isModified() {
388
    return this.modified.get();
389
  }
390
391
  ReadOnlyBooleanProperty modifiedProperty() {
392
    return this.modified.getReadOnlyProperty();
393
  }
394
395
  BooleanProperty canUndoProperty() {
396
    return this.canUndo;
397
  }
398
399
  BooleanProperty canRedoProperty() {
400
    return this.canRedo;
401
  }
402
403
  private UndoManager getUndoManager() {
404
    return getEditorPane().getUndoManager();
405
  }
406
407
  /**
408
   * Forwards the request to the editor pane.
409
   *
410
   * @param <T> The type of event listener to add.
411
   * @param <U> The type of consumer to add.
412
   * @param event The event that should trigger updates to the listener.
413
   * @param consumer The listener to receive update events.
414
   */
415
  public <T extends Event, U extends T> void addEventListener(
416
    final EventPattern<? super T, ? extends U> event,
417
    final Consumer<? super U> consumer ) {
418
    getEditorPane().addEventListener( event, consumer );
419
  }
420
421
  /**
422
   * Forwards to the editor pane's listeners for keyboard events.
423
   *
424
   * @param map The new input map to replace the existing keyboard listener.
425
   */
426
  public void addEventListener( final InputMap<InputEvent> map ) {
427
    getEditorPane().addEventListener( map );
428
  }
429
430
  /**
431
   * Forwards to the editor pane's listeners for keyboard events.
432
   *
433
   * @param map The existing input map to remove from the keyboard listeners.
434
   */
435
  public void removeEventListener( final InputMap<InputEvent> map ) {
436
    getEditorPane().removeEventListener( map );
437
  }
438
439
  /**
440
   * Forwards to the editor pane's listeners for text change events.
441
   *
442
   * @param listener The listener to notify when the text changes.
443
   */
444
  public void addTextChangeListener( final ChangeListener<String> listener ) {
445
    getEditorPane().addTextChangeListener( listener );
446
  }
447
448
  /**
449
   * Forwards to the editor pane's listeners for caret paragraph change events.
450
   *
451
   * @param listener The listener to notify when the caret changes paragraphs.
452
   */
453
  public void addCaretParagraphListener( final ChangeListener<Integer> listener ) {
454
    getEditorPane().addCaretParagraphListener( listener );
455
  }
456
457
  /**
458
   * Forwards the request to the editor pane.
459
   *
460
   * @return The text to process.
461
   */
462
  public String getEditorText() {
463
    return getEditorPane().getText();
464
  }
465
466
  /**
467
   * Returns the editor pane, or creates one if it doesn't yet exist.
468
   *
469
   * @return The editor pane, never null.
470
   */
471
  public EditorPane getEditorPane() {
472
    if( this.editorPane == null ) {
473
      this.editorPane = new MarkdownEditorPane();
474
    }
475
476
    return this.editorPane;
477
  }
478
479
  private Notifier getNotifyService() {
480
    return this.alertService;
481
  }
482
483
  private Charset getEncoding() {
33
import static java.nio.charset.StandardCharsets.UTF_8;
34
import java.nio.file.Files;
35
import java.nio.file.Path;
36
import static java.util.Locale.ENGLISH;
37
import java.util.function.Consumer;
38
import javafx.application.Platform;
39
import javafx.beans.binding.Bindings;
40
import javafx.beans.property.BooleanProperty;
41
import javafx.beans.property.ReadOnlyBooleanProperty;
42
import javafx.beans.property.ReadOnlyBooleanWrapper;
43
import javafx.beans.property.SimpleBooleanProperty;
44
import javafx.beans.value.ChangeListener;
45
import javafx.beans.value.ObservableValue;
46
import javafx.event.Event;
47
import javafx.scene.Node;
48
import javafx.scene.Scene;
49
import javafx.scene.control.Tab;
50
import javafx.scene.control.Tooltip;
51
import javafx.scene.input.InputEvent;
52
import javafx.scene.text.Text;
53
import javafx.stage.Window;
54
import org.fxmisc.richtext.StyleClassedTextArea;
55
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
56
import org.fxmisc.richtext.model.TwoDimensional.Position;
57
import org.fxmisc.undo.UndoManager;
58
import org.fxmisc.wellbehaved.event.EventPattern;
59
import org.fxmisc.wellbehaved.event.InputMap;
60
import org.mozilla.universalchardet.UniversalDetector;
61
62
/**
63
 * Editor for a single file.
64
 *
65
 * @author Karl Tauber and White Magic Software, Ltd.
66
 */
67
public final class FileEditorTab extends Tab {
68
69
  private final Notifier alertService = Services.load( Notifier.class );
70
  private EditorPane editorPane;
71
72
  /**
73
   * Character encoding used by the file (or default encoding if none found).
74
   */
75
  private Charset encoding;
76
77
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
78
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
79
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
80
81
  private Path path;
82
83
  FileEditorTab( final Path path ) {
84
    setPath( path );
85
86
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
87
88
    setOnSelectionChanged( e -> {
89
      if( isSelected() ) {
90
        Platform.runLater( () -> activated() );
91
      }
92
    } );
93
  }
94
95
  private void updateTab() {
96
    setText( getTabTitle() );
97
    setGraphic( getModifiedMark() );
98
    setTooltip( getTabTooltip() );
99
  }
100
101
  /**
102
   * Returns the base filename (without the directory names).
103
   *
104
   * @return The untitled text if the path hasn't been set.
105
   */
106
  private String getTabTitle() {
107
    final Path filePath = getPath();
108
109
    return (filePath == null)
110
      ? Messages.get( "FileEditor.untitled" )
111
      : filePath.getFileName().toString();
112
  }
113
114
  /**
115
   * Returns the full filename represented by the path.
116
   *
117
   * @return The untitled text if the path hasn't been set.
118
   */
119
  private Tooltip getTabTooltip() {
120
    final Path filePath = getPath();
121
    return new Tooltip( filePath == null ? "" : filePath.toString() );
122
  }
123
124
  /**
125
   * Returns a marker to indicate whether the file has been modified.
126
   *
127
   * @return "*" when the file has changed; otherwise null.
128
   */
129
  private Text getModifiedMark() {
130
    return isModified() ? new Text( "*" ) : null;
131
  }
132
133
  /**
134
   * Called when the user switches tab.
135
   */
136
  private void activated() {
137
    // Tab is closed or no longer active.
138
    if( getTabPane() == null || !isSelected() ) {
139
      return;
140
    }
141
142
    // Switch to the tab without loading if the contents are already in memory.
143
    if( getContent() != null ) {
144
      getEditorPane().requestFocus();
145
      return;
146
    }
147
148
    // Load the text and update the preview before the undo manager.
149
    load();
150
151
    // Track undo requests -- can only be called *after* load.
152
    initUndoManager();
153
    initLayout();
154
    initFocus();
155
  }
156
157
  private void initLayout() {
158
    setContent( getScrollPane() );
159
  }
160
161
  private Node getScrollPane() {
162
    return getEditorPane().getScrollPane();
163
  }
164
165
  private void initFocus() {
166
    getEditorPane().requestFocus();
167
  }
168
169
  private void initUndoManager() {
170
    final UndoManager undoManager = getUndoManager();
171
172
    // Clear undo history after first load.
173
    undoManager.forgetHistory();
174
175
    // Bind the editor undo manager to the properties.
176
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
177
    canUndo.bind( undoManager.undoAvailableProperty() );
178
    canRedo.bind( undoManager.redoAvailableProperty() );
179
  }
180
181
  /**
182
   * Searches from the caret position forward for the given string.
183
   *
184
   * @param needle The text string to match.
185
   */
186
  public void searchNext( final String needle ) {
187
    final String haystack = getEditorText();
188
    int index = haystack.indexOf( needle, getCaretPosition() );
189
190
    // Wrap around.
191
    if( index == -1 ) {
192
      index = haystack.indexOf( needle, 0 );
193
    }
194
195
    if( index >= 0 ) {
196
      setCaretPosition( index );
197
      getEditor().selectRange( index, index + needle.length() );
198
    }
199
  }
200
201
  /**
202
   * Returns the index into the text where the caret blinks happily away.
203
   *
204
   * @return A number from 0 to the editor's document text length.
205
   */
206
  public int getCaretPosition() {
207
    return getEditor().getCaretPosition();
208
  }
209
210
  /**
211
   * Moves the caret to a given offset.
212
   *
213
   * @param offset The new caret offset.
214
   */
215
  private void setCaretPosition( final int offset ) {
216
    getEditor().moveTo( offset );
217
    getEditor().requestFollowCaret();
218
  }
219
220
  /**
221
   * Returns the caret's current row and column position.
222
   *
223
   * @return The caret's offset into the document.
224
   */
225
  public Position getCaretOffset() {
226
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
227
  }
228
229
  /**
230
   * Allows observers to synchronize caret position changes.
231
   *
232
   * @return An observable caret property value.
233
   */
234
  public final ObservableValue<Integer> caretPositionProperty() {
235
    return getEditor().caretPositionProperty();
236
  }
237
238
  /**
239
   * Returns the text area associated with this tab.
240
   *
241
   * @return A text editor.
242
   */
243
  private StyleClassedTextArea getEditor() {
244
    return getEditorPane().getEditor();
245
  }
246
247
  /**
248
   * Returns true if the given path exactly matches this tab's path.
249
   *
250
   * @param check The path to compare against.
251
   *
252
   * @return true The paths are the same.
253
   */
254
  public boolean isPath( final Path check ) {
255
    final Path filePath = getPath();
256
257
    return filePath == null ? false : filePath.equals( check );
258
  }
259
260
  /**
261
   * Reads the entire file contents from the path associated with this tab.
262
   */
263
  private void load() {
264
    final Path filePath = getPath();
265
266
    if( filePath != null ) {
267
      try {
268
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
269
      } catch( final Exception ex ) {
270
        getNotifyService().notify( ex );
271
      }
272
    }
273
  }
274
275
  /**
276
   * Saves the entire file contents from the path associated with this tab.
277
   *
278
   * @return true The file has been saved.
279
   */
280
  public boolean save() {
281
    try {
282
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
283
      getEditorPane().getUndoManager().mark();
284
      return true;
285
    } catch( final Exception ex ) {
286
      return alert(
287
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
288
      );
289
    }
290
  }
291
292
  /**
293
   * Creates an alert dialog and waits for it to close.
294
   *
295
   * @param titleKey Resource bundle key for the alert dialog title.
296
   * @param messageKey Resource bundle key for the alert dialog message.
297
   * @param e The unexpected happening.
298
   *
299
   * @return false
300
   */
301
  private boolean alert(
302
    final String titleKey, final String messageKey, final Exception e ) {
303
    final Notifier service = getNotifyService();
304
    final Path filePath = getPath();
305
306
    final Notification message = service.createNotification(
307
      Messages.get( titleKey ),
308
      Messages.get( messageKey ),
309
      filePath == null ? "" : filePath,
310
      e.getMessage()
311
    );
312
313
    try {
314
      service.createError( getWindow(), message ).showAndWait();
315
    } catch( final Exception ex ) {
316
      getNotifyService().notify( ex );
317
    }
318
    
319
    return false;
320
  }
321
322
  private Window getWindow() {
323
    final Scene scene = getEditorPane().getScene();
324
325
    if( scene == null ) {
326
      throw new UnsupportedOperationException( "" );
327
    }
328
329
    return scene.getWindow();
330
  }
331
332
  /**
333
   * Returns a best guess at the file encoding. If the encoding could not be
334
   * detected, this will return the default charset for the JVM.
335
   *
336
   * @param bytes The bytes to perform character encoding detection.
337
   *
338
   * @return The character encoding.
339
   */
340
  private Charset detectEncoding( final byte[] bytes ) {
341
    final UniversalDetector detector = new UniversalDetector( null );
342
    detector.handleData( bytes, 0, bytes.length );
343
    detector.dataEnd();
344
345
    final String charset = detector.getDetectedCharset();
346
    final Charset charEncoding = charset == null
347
      ? Charset.defaultCharset()
348
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
349
350
    detector.reset();
351
352
    return charEncoding;
353
  }
354
355
  /**
356
   * Converts the given string to an array of bytes using the encoding that was
357
   * originally detected (if any) and associated with this file.
358
   *
359
   * @param text The text to convert into the original file encoding.
360
   *
361
   * @return A series of bytes ready for writing to a file.
362
   */
363
  private byte[] asBytes( final String text ) {
364
    return text.getBytes( getEncoding() );
365
  }
366
367
  /**
368
   * Converts the given bytes into a Java String. This will call setEncoding
369
   * with the encoding detected by the CharsetDetector.
370
   *
371
   * @param text The text of unknown character encoding.
372
   *
373
   * @return The text, in its auto-detected encoding, as a String.
374
   */
375
  private String asString( final byte[] text ) {
376
    setEncoding( detectEncoding( text ) );
377
    return new String( text, getEncoding() );
378
  }
379
380
  public Path getPath() {
381
    return this.path;
382
  }
383
384
  public void setPath( final Path path ) {
385
    this.path = path;
386
387
    updateTab();
388
  }
389
390
  /**
391
   * Answers whether this tab has an initialized path reference.
392
   *
393
   * @return false This tab has no path.
394
   */
395
  public boolean isFileOpen() {
396
    return this.path != null;
397
  }
398
399
  public boolean isModified() {
400
    return this.modified.get();
401
  }
402
403
  ReadOnlyBooleanProperty modifiedProperty() {
404
    return this.modified.getReadOnlyProperty();
405
  }
406
407
  BooleanProperty canUndoProperty() {
408
    return this.canUndo;
409
  }
410
411
  BooleanProperty canRedoProperty() {
412
    return this.canRedo;
413
  }
414
415
  private UndoManager getUndoManager() {
416
    return getEditorPane().getUndoManager();
417
  }
418
419
  /**
420
   * Forwards the request to the editor pane.
421
   *
422
   * @param <T> The type of event listener to add.
423
   * @param <U> The type of consumer to add.
424
   * @param event The event that should trigger updates to the listener.
425
   * @param consumer The listener to receive update events.
426
   */
427
  public <T extends Event, U extends T> void addEventListener(
428
    final EventPattern<? super T, ? extends U> event,
429
    final Consumer<? super U> consumer ) {
430
    getEditorPane().addEventListener( event, consumer );
431
  }
432
433
  /**
434
   * Forwards to the editor pane's listeners for keyboard events.
435
   *
436
   * @param map The new input map to replace the existing keyboard listener.
437
   */
438
  public void addEventListener( final InputMap<InputEvent> map ) {
439
    getEditorPane().addEventListener( map );
440
  }
441
442
  /**
443
   * Forwards to the editor pane's listeners for keyboard events.
444
   *
445
   * @param map The existing input map to remove from the keyboard listeners.
446
   */
447
  public void removeEventListener( final InputMap<InputEvent> map ) {
448
    getEditorPane().removeEventListener( map );
449
  }
450
451
  /**
452
   * Forwards to the editor pane's listeners for text change events.
453
   *
454
   * @param listener The listener to notify when the text changes.
455
   */
456
  public void addTextChangeListener( final ChangeListener<String> listener ) {
457
    getEditorPane().addTextChangeListener( listener );
458
  }
459
460
  /**
461
   * Forwards to the editor pane's listeners for caret paragraph change events.
462
   *
463
   * @param listener The listener to notify when the caret changes paragraphs.
464
   */
465
  public void addCaretParagraphListener( final ChangeListener<Integer> listener ) {
466
    getEditorPane().addCaretParagraphListener( listener );
467
  }
468
469
  /**
470
   * Forwards the request to the editor pane.
471
   *
472
   * @return The text to process.
473
   */
474
  public String getEditorText() {
475
    return getEditorPane().getText();
476
  }
477
478
  /**
479
   * Returns the editor pane, or creates one if it doesn't yet exist.
480
   *
481
   * @return The editor pane, never null.
482
   */
483
  public synchronized EditorPane getEditorPane() {
484
    if( this.editorPane == null ) {
485
      this.editorPane = new MarkdownEditorPane();
486
    }
487
488
    return this.editorPane;
489
  }
490
491
  private Notifier getNotifyService() {
492
    return this.alertService;
493
  }
494
495
  /**
496
   * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been
497
   * determined.
498
   * 
499
   * @return The file encoding or UTF-8 if unknown.
500
   */
501
  private Charset getEncoding() {
502
    if( this.encoding == null ) {
503
      this.encoding = UTF_8;
504
    }
505
    
484506
    return this.encoding;
485507
  }
M src/main/java/com/scrivenvar/FileEditorTabPane.java
6868
import org.fxmisc.wellbehaved.event.EventPattern;
6969
import org.fxmisc.wellbehaved.event.InputMap;
70
import static com.scrivenvar.Messages.get;
71
import static com.scrivenvar.Messages.get;
72
import static com.scrivenvar.Messages.get;
73
import static com.scrivenvar.Messages.get;
74
import static com.scrivenvar.Messages.get;
75
import static com.scrivenvar.Messages.get;
76
import static com.scrivenvar.Messages.get;
77
78
/**
79
 * Tab pane for file editors.
80
 *
81
 * @author Karl Tauber and White Magic Software, Ltd.
82
 */
83
public final class FileEditorTabPane extends TabPane {
84
85
  private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter";
86
87
  private final Options options = Services.load( Options.class );
88
  private final Settings settings = Services.load( Settings.class );
89
  private final Notifier notifyService = Services.load(Notifier.class );
90
91
  private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>();
92
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
93
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
94
95
  /**
96
   * Constructs a new file editor tab pane.
97
   */
98
  public FileEditorTabPane() {
99
    final ObservableList<Tab> tabs = getTabs();
100
101
    setFocusTraversable( false );
102
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
103
104
    addTabSelectionListener(
105
      (ObservableValue<? extends Tab> tabPane,
106
        final Tab oldTab, final Tab newTab) -> {
107
108
        if( newTab != null ) {
109
          activeFileEditor.set( (FileEditorTab)newTab );
110
        }
111
      }
112
    );
113
114
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
115
      for( final Tab tab : tabs ) {
116
        if( ((FileEditorTab)tab).isModified() ) {
117
          this.anyFileEditorModified.set( true );
118
          break;
119
        }
120
      }
121
    };
122
123
    tabs.addListener(
124
      (ListChangeListener<Tab>)change -> {
125
        while( change.next() ) {
126
          if( change.wasAdded() ) {
127
            change.getAddedSubList().stream().forEach( (tab) -> {
128
              ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener );
129
            } );
130
          } else if( change.wasRemoved() ) {
131
            change.getRemoved().stream().forEach( (tab) -> {
132
              ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener );
133
            } );
134
          }
135
        }
136
137
        // Changes in the tabs may also change anyFileEditorModified property
138
        // (e.g. closed modified file)
139
        modifiedListener.changed( null, null, null );
140
      }
141
    );
142
  }
143
144
  /**
145
   * Delegates to the active file editor.
146
   *
147
   * @param <T> Event type.
148
   * @param <U> Consumer type.
149
   * @param event Event to pass to the editor.
150
   * @param consumer Consumer to pass to the editor.
151
   */
152
  public <T extends Event, U extends T> void addEventListener(
153
    final EventPattern<? super T, ? extends U> event,
154
    final Consumer<? super U> consumer ) {
155
    getActiveFileEditor().addEventListener( event, consumer );
156
  }
157
158
  /**
159
   * Delegates to the active file editor pane, and, ultimately, to its text
160
   * area.
161
   *
162
   * @param map The map of methods to events.
163
   */
164
  public void addEventListener( final InputMap<InputEvent> map ) {
165
    getActiveFileEditor().addEventListener( map );
166
  }
167
168
  /**
169
   * Remove a keyboard event listener from the active file editor.
170
   *
171
   * @param map The keyboard events to remove.
172
   */
173
  public void removeEventListener( final InputMap<InputEvent> map ) {
174
    getActiveFileEditor().removeEventListener( map );
175
  }
176
177
  /**
178
   * Allows observers to be notified when the current file editor tab changes.
179
   *
180
   * @param listener The listener to notify of tab change events.
181
   */
182
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
183
    // Observe the tab so that when a new tab is opened or selected,
184
    // a notification is kicked off.
185
    getSelectionModel().selectedItemProperty().addListener( listener );
186
  }
187
188
  /**
189
   * Allows clients to manipulate the editor content directly.
190
   *
191
   * @return The text area for the active file editor.
192
   */
193
  public StyledTextArea getEditor() {
194
    return getActiveFileEditor().getEditorPane().getEditor();
195
  }
196
197
  public FileEditorTab getActiveFileEditor() {
198
    return this.activeFileEditor.get();
199
  }
200
201
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
202
    return this.activeFileEditor.getReadOnlyProperty();
203
  }
204
205
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
206
    return this.anyFileEditorModified.getReadOnlyProperty();
207
  }
208
209
  private FileEditorTab createFileEditor( final Path path ) {
210
    final FileEditorTab tab = new FileEditorTab( path );
211
212
    tab.setOnCloseRequest( e -> {
213
      if( !canCloseEditor( tab ) ) {
214
        e.consume();
215
      }
216
    } );
217
218
    return tab;
219
  }
220
221
  /**
222
   * Called when the user selects New from the File menu.
223
   *
224
   * @return The newly added tab.
225
   */
226
  void newEditor() {
227
    final FileEditorTab tab = createFileEditor( null );
228
229
    getTabs().add( tab );
230
    getSelectionModel().select( tab );
231
  }
232
233
  void openFileDialog() {
234
    final String title = get( "Dialog.file.choose.open.title" );
235
    final FileChooser dialog = createFileChooser( title );
236
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
237
238
    if( files != null ) {
239
      openFiles( files );
240
    }
241
  }
242
243
  /**
244
   * Opens the files into new editors, unless one of those files was a
245
   * definition file. The definition file is loaded into the definition pane,
246
   * but only the first one selected (multiple definition files will result in a
247
   * warning).
248
   *
249
   * @param files The list of non-definition files that the were requested to
250
   * open.
251
   *
252
   * @return A list of files that can be opened in text editors.
253
   */
254
  private void openFiles( final List<File> files ) {
255
    final FileTypePredicate predicate
256
      = new FileTypePredicate( createExtensionFilter( DEFINITION ).getExtensions() );
257
258
    // The user might have opened multiple definitions files. These will
259
    // be discarded from the text editable files.
260
    final List<File> definitions
261
      = files.stream().filter( predicate ).collect( Collectors.toList() );
262
263
    // Create a modifiable list to remove any definition files that were
264
    // opened.
265
    final List<File> editors = new ArrayList<>( files );
266
267
    if( editors.size() > 0 ) {
268
      saveLastDirectory( editors.get( 0 ) );
269
    }
270
271
    editors.removeAll( definitions );
272
273
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
274
    if( editors.size() > 0 ) {
275
      openEditors( editors, 0 );
276
    }
277
278
    if( definitions.size() > 0 ) {
279
      openDefinition( definitions.get( 0 ) );
280
    }
281
  }
282
283
  private void openEditors( final List<File> files, final int activeIndex ) {
284
    final int fileTally = files.size();
285
    final List<Tab> tabs = getTabs();
286
287
    // Close single unmodified "Untitled" tab.
288
    if( tabs.size() == 1 ) {
289
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ));
290
291
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
292
        closeEditor( fileEditor, false );
293
      }
294
    }
295
296
    for( int i = 0; i < fileTally; i++ ) {
297
      final Path path = files.get( i ).toPath();
298
299
      FileEditorTab fileEditorTab = findEditor( path );
300
301
      // Only open new files.
302
      if( fileEditorTab == null ) {
303
        fileEditorTab = createFileEditor( path );
304
        getTabs().add( fileEditorTab );
305
      }
306
307
      // Select the first file in the list.
308
      if( i == activeIndex ) {
309
        getSelectionModel().select( fileEditorTab );
310
      }
311
    }
312
  }
313
314
  /**
315
   * Returns a property that changes when a new definition file is opened.
316
   *
317
   * @return The path to a definition file that was opened.
318
   */
319
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
320
    return getOnOpenDefinitionFile().getReadOnlyProperty();
321
  }
322
323
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
324
    return this.openDefinition;
325
  }
326
327
  /**
328
   * Called when the user has opened a definition file (using the file open
329
   * dialog box). This will replace the current set of definitions for the
330
   * active tab.
331
   *
332
   * @param definition The file to open.
333
   */
334
  private void openDefinition( final File definition ) {
335
    // TODO: Prevent reading this file twice when a new text document is opened.
336
    // (might be a matter of checking the value first).
337
    getOnOpenDefinitionFile().set( definition.toPath() );
338
  }
339
340
  boolean saveEditor( final FileEditorTab fileEditor ) {
341
    if( fileEditor == null || !fileEditor.isModified() ) {
342
      return true;
343
    }
344
345
    if( fileEditor.getPath() == null ) {
346
      getSelectionModel().select( fileEditor );
347
348
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
349
      final File file = fileChooser.showSaveDialog( getWindow() );
350
      if( file == null ) {
351
        return false;
352
      }
353
354
      saveLastDirectory( file );
355
      fileEditor.setPath( file.toPath() );
356
    }
357
358
    return fileEditor.save();
359
  }
360
361
  boolean saveAllEditors() {
362
    boolean success = true;
363
364
    for( FileEditorTab fileEditor : getAllEditors() ) {
365
      if( !saveEditor( fileEditor ) ) {
366
        success = false;
367
      }
368
    }
369
370
    return success;
371
  }
372
373
  /**
374
   * Answers whether the file has had modifications. '
375
   *
376
   * @param tab THe tab to check for modifications.
377
   *
378
   * @return false The file is unmodified.
379
   */
380
  boolean canCloseEditor( final FileEditorTab tab ) {
381
    if( !tab.isModified() ) {
382
      return true;
383
    }
384
385
    final Notification message = getNotifyService().createNotification(
386
      Messages.get( "Alert.file.close.title" ),
387
      Messages.get( "Alert.file.close.text" ),
388
      tab.getText()
389
    );
390
391
    final Alert alert = getNotifyService().createConfirmation(
392
      getWindow(), message );
393
    final ButtonType response = alert.showAndWait().get();
394
395
    return response == YES ? saveEditor( tab ) : response == NO;
396
  }
397
398
  private Notifier getNotifyService() {
399
    return this.notifyService;
400
  }
401
402
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
403
    if( fileEditor == null ) {
404
      return true;
405
    }
406
407
    final Tab tab = fileEditor;
408
409
    if( save ) {
410
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
411
      Event.fireEvent( tab, event );
412
413
      if( event.isConsumed() ) {
414
        return false;
415
      }
416
    }
417
418
    getTabs().remove( tab );
419
420
    if( tab.getOnClosed() != null ) {
421
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
422
    }
423
424
    return true;
425
  }
426
427
  boolean closeAllEditors() {
428
    final FileEditorTab[] allEditors = getAllEditors();
429
    final FileEditorTab activeEditor = getActiveFileEditor();
430
431
    // try to save active tab first because in case the user decides to cancel,
432
    // then it stays active
433
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
434
      return false;
435
    }
436
437
    // This should be called any time a tab changes.
438
    persistPreferences();
439
440
    // save modified tabs
441
    for( int i = 0; i < allEditors.length; i++ ) {
442
      final FileEditorTab fileEditor = allEditors[ i ];
443
444
      if( fileEditor == activeEditor ) {
445
        continue;
446
      }
447
448
      if( fileEditor.isModified() ) {
449
        // activate the modified tab to make its modified content visible to the user
450
        getSelectionModel().select( i );
451
452
        if( !canCloseEditor( fileEditor ) ) {
453
          return false;
454
        }
455
      }
456
    }
457
458
    // Close all tabs.
459
    for( final FileEditorTab fileEditor : allEditors ) {
460
      if( !closeEditor( fileEditor, false ) ) {
461
        return false;
462
      }
463
    }
464
465
    return getTabs().isEmpty();
466
  }
467
468
  private FileEditorTab[] getAllEditors() {
469
    final ObservableList<Tab> tabs = getTabs();
470
    final int length = tabs.size();
471
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
472
473
    for( int i = 0; i < length; i++ ) {
474
      allEditors[ i ] = (FileEditorTab)tabs.get( i );
475
    }
476
477
    return allEditors;
478
  }
479
480
  /**
481
   * Returns the file editor tab that has the given path.
482
   *
483
   * @return null No file editor tab for the given path was found.
484
   */
485
  private FileEditorTab findEditor( final Path path ) {
486
    for( final Tab tab : getTabs() ) {
487
      final FileEditorTab fileEditor = (FileEditorTab)tab;
488
489
      if( fileEditor.isPath( path ) ) {
490
        return fileEditor;
491
      }
492
    }
493
494
    return null;
495
  }
496
497
  private FileChooser createFileChooser( String title ) {
498
    final FileChooser fileChooser = new FileChooser();
499
500
    fileChooser.setTitle( title );
501
    fileChooser.getExtensionFilters().addAll(
502
      createExtensionFilters() );
503
504
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
505
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
506
507
    if( !file.isDirectory() ) {
508
      file = new File( "." );
509
    }
510
511
    fileChooser.setInitialDirectory( file );
512
    return fileChooser;
513
  }
514
515
  private List<ExtensionFilter> createExtensionFilters() {
516
    final List<ExtensionFilter> list = new ArrayList<>();
517
518
    // TODO: Return a list of all properties that match the filter prefix.
519
    // This will allow dynamic filters to be added and removed just by
520
    // updating the properties file.
521
    list.add( createExtensionFilter( MARKDOWN ) );
522
    list.add( createExtensionFilter( DEFINITION ) );
523
    list.add( createExtensionFilter( XML ) );
524
    list.add( createExtensionFilter( ALL ) );
525
    return list;
526
  }
527
528
  /**
529
   * Returns a filter for file name extensions recognized by the application
530
   * that can be opened by the user.
531
   *
532
   * @param filetype Used to find the globbing pattern for extensions.
533
   *
534
   * @return A filename filter suitable for use by a FileDialog instance.
535
   */
536
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
537
    final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype );
538
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
539
540
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
541
  }
542
543
  private List<String> getExtensions( final String key ) {
544
    return getSettings().getStringSettingList( key );
545
  }
546
547
  private void saveLastDirectory( final File file ) {
548
    getPreferences().put( "lastDirectory", file.getParent() );
549
  }
550
551
  public void restorePreferences() {
552
    int activeIndex = 0;
553
554
    final Preferences preferences = getPreferences();
555
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
556
    final String activeFileName = preferences.get( "activeFile", null );
557
558
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
559
560
    for( final String fileName : fileNames ) {
561
      final File file = new File( fileName );
562
563
      if( file.exists() ) {
564
        files.add( file );
565
566
        if( fileName.equals( activeFileName ) ) {
567
          activeIndex = files.size() - 1;
568
        }
569
      }
570
    }
571
572
    if( files.isEmpty() ) {
573
      newEditor();
574
    } else {
575
      openEditors( files, activeIndex );
576
    }
577
  }
578
579
  public void persistPreferences() {
580
    final ObservableList<Tab> allEditors = getTabs();
581
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
582
583
    for( final Tab tab : allEditors ) {
584
      final FileEditorTab fileEditor = (FileEditorTab)tab;
585
      final Path filePath = fileEditor.getPath();
586
587
      if( filePath != null ) {
588
        fileNames.add( filePath.toString() );
589
      }
590
    }
591
592
    final Preferences preferences = getPreferences();
593
    Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
594
595
    final FileEditorTab activeEditor = getActiveFileEditor();
596
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
597
598
    if( filePath == null ) {
599
      preferences.remove( "activeFile" );
600
    } else {
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 Notifier notifyService = Services.load( Notifier.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
          }
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 tab ) {
335
    if( tab == null || !tab.isModified() ) {
336
      return true;
337
    }
338
339
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
340
  }
341
342
  boolean saveEditorAs( final FileEditorTab tab ) {
343
    if( tab == null ) {
344
      return true;
345
    }
346
347
    getSelectionModel().select( tab );
348
349
    final FileChooser fileChooser = createFileChooser( get( "Dialog.file.choose.save.title" ) );
350
    final File file = fileChooser.showSaveDialog( getWindow() );
351
    if( file == null ) {
352
      return false;
353
    }
354
355
    saveLastDirectory( file );
356
    tab.setPath( file.toPath() );
357
358
    return tab.save();
359
  }
360
361
  boolean saveAllEditors() {
362
    boolean success = true;
363
364
    for( FileEditorTab fileEditor : getAllEditors() ) {
365
      if( !saveEditor( fileEditor ) ) {
366
        success = false;
367
      }
368
    }
369
370
    return success;
371
  }
372
373
  /**
374
   * Answers whether the file has had modifications. '
375
   *
376
   * @param tab THe tab to check for modifications.
377
   *
378
   * @return false The file is unmodified.
379
   */
380
  boolean canCloseEditor( final FileEditorTab tab ) {
381
    if( !tab.isModified() ) {
382
      return true;
383
    }
384
385
    final Notification message = getNotifyService().createNotification(
386
      Messages.get( "Alert.file.close.title" ),
387
      Messages.get( "Alert.file.close.text" ),
388
      tab.getText()
389
    );
390
391
    final Alert alert = getNotifyService().createConfirmation(
392
      getWindow(), message );
393
    final ButtonType response = alert.showAndWait().get();
394
395
    return response == YES ? saveEditor( tab ) : response == NO;
396
  }
397
398
  private Notifier getNotifyService() {
399
    return this.notifyService;
400
  }
401
402
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
403
    if( fileEditor == null ) {
404
      return true;
405
    }
406
407
    final Tab tab = fileEditor;
408
409
    if( save ) {
410
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
411
      Event.fireEvent( tab, event );
412
413
      if( event.isConsumed() ) {
414
        return false;
415
      }
416
    }
417
418
    getTabs().remove( tab );
419
420
    if( tab.getOnClosed() != null ) {
421
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
422
    }
423
424
    return true;
425
  }
426
427
  boolean closeAllEditors() {
428
    final FileEditorTab[] allEditors = getAllEditors();
429
    final FileEditorTab activeEditor = getActiveFileEditor();
430
431
    // try to save active tab first because in case the user decides to cancel,
432
    // then it stays active
433
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
434
      return false;
435
    }
436
437
    // This should be called any time a tab changes.
438
    persistPreferences();
439
440
    // save modified tabs
441
    for( int i = 0; i < allEditors.length; i++ ) {
442
      final FileEditorTab fileEditor = allEditors[ i ];
443
444
      if( fileEditor == activeEditor ) {
445
        continue;
446
      }
447
448
      if( fileEditor.isModified() ) {
449
        // activate the modified tab to make its modified content visible to the user
450
        getSelectionModel().select( i );
451
452
        if( !canCloseEditor( fileEditor ) ) {
453
          return false;
454
        }
455
      }
456
    }
457
458
    // Close all tabs.
459
    for( final FileEditorTab fileEditor : allEditors ) {
460
      if( !closeEditor( fileEditor, false ) ) {
461
        return false;
462
      }
463
    }
464
465
    return getTabs().isEmpty();
466
  }
467
468
  private FileEditorTab[] getAllEditors() {
469
    final ObservableList<Tab> tabs = getTabs();
470
    final int length = tabs.size();
471
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
472
473
    for( int i = 0; i < length; i++ ) {
474
      allEditors[ i ] = (FileEditorTab)tabs.get( i );
475
    }
476
477
    return allEditors;
478
  }
479
480
  /**
481
   * Returns the file editor tab that has the given path.
482
   *
483
   * @return null No file editor tab for the given path was found.
484
   */
485
  private FileEditorTab findEditor( final Path path ) {
486
    for( final Tab tab : getTabs() ) {
487
      final FileEditorTab fileEditor = (FileEditorTab)tab;
488
489
      if( fileEditor.isPath( path ) ) {
490
        return fileEditor;
491
      }
492
    }
493
494
    return null;
495
  }
496
497
  private FileChooser createFileChooser( String title ) {
498
    final FileChooser fileChooser = new FileChooser();
499
500
    fileChooser.setTitle( title );
501
    fileChooser.getExtensionFilters().addAll(
502
      createExtensionFilters() );
503
504
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
505
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
506
507
    if( !file.isDirectory() ) {
508
      file = new File( "." );
509
    }
510
511
    fileChooser.setInitialDirectory( file );
512
    return fileChooser;
513
  }
514
515
  private List<ExtensionFilter> createExtensionFilters() {
516
    final List<ExtensionFilter> list = new ArrayList<>();
517
518
    // TODO: Return a list of all properties that match the filter prefix.
519
    // This will allow dynamic filters to be added and removed just by
520
    // updating the properties file.
521
    list.add( createExtensionFilter( MARKDOWN ) );
522
    list.add( createExtensionFilter( DEFINITION ) );
523
    list.add( createExtensionFilter( XML ) );
524
    list.add( createExtensionFilter( ALL ) );
525
    return list;
526
  }
527
528
  /**
529
   * Returns a filter for file name extensions recognized by the application
530
   * that can be opened by the user.
531
   *
532
   * @param filetype Used to find the globbing pattern for extensions.
533
   *
534
   * @return A filename filter suitable for use by a FileDialog instance.
535
   */
536
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
537
    final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype );
538
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
539
540
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
541
  }
542
543
  private List<String> getExtensions( final String key ) {
544
    return getSettings().getStringSettingList( key );
545
  }
546
547
  private void saveLastDirectory( final File file ) {
548
    getPreferences().put( "lastDirectory", file.getParent() );
549
  }
550
551
  public void restorePreferences() {
552
    int activeIndex = 0;
553
554
    final Preferences preferences = getPreferences();
555
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
556
    final String activeFileName = preferences.get( "activeFile", null );
557
558
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
559
560
    for( final String fileName : fileNames ) {
561
      final File file = new File( fileName );
562
563
      if( file.exists() ) {
564
        files.add( file );
565
566
        if( fileName.equals( activeFileName ) ) {
567
          activeIndex = files.size() - 1;
568
        }
569
      }
570
    }
571
572
    if( files.isEmpty() ) {
573
      newEditor();
574
    }
575
    else {
576
      openEditors( files, activeIndex );
577
    }
578
  }
579
580
  public void persistPreferences() {
581
    final ObservableList<Tab> allEditors = getTabs();
582
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
583
584
    for( final Tab tab : allEditors ) {
585
      final FileEditorTab fileEditor = (FileEditorTab)tab;
586
      final Path filePath = fileEditor.getPath();
587
588
      if( filePath != null ) {
589
        fileNames.add( filePath.toString() );
590
      }
591
    }
592
593
    final Preferences preferences = getPreferences();
594
    Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
595
596
    final FileEditorTab activeEditor = getActiveFileEditor();
597
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
598
599
    if( filePath == null ) {
600
      preferences.remove( "activeFile" );
601
    }
602
    else {
601603
      preferences.put( "activeFile", filePath.toString() );
602604
    }
M src/main/java/com/scrivenvar/FileType.java
4343
  DEFINITION( "definition" ),
4444
  XML( "xml" ),
45
  CSV( "csv" ),
4546
  JSON( "json" ),
4647
  TOML( "toml" ),
M src/main/java/com/scrivenvar/MainWindow.java
522522
  }
523523
524
  private void fileSaveAll() {
525
    getFileEditorPane().saveAllEditors();
526
  }
527
528
  private void fileExit() {
529
    final Window window = getWindow();
530
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
531
  }
532
533
  //---- Tools actions
534
  private void toolsScript() {
535
    final String script = getStartupScript();
536
537
    final RScriptDialog dialog = new RScriptDialog(
538
      getWindow(), "Dialog.rScript.title", script );
539
    final Optional<String> result = dialog.showAndWait();
540
541
    result.ifPresent( (String s) -> {
542
      putStartupScript( s );
543
    } );
544
  }
545
546
  /**
547
   * Gets the R startup script from the user preferences.
548
   */
549
  private String getStartupScript() {
550
    return getPreferences().get( PERSIST_R_STARTUP, "" );
551
  }
552
553
  /**
554
   * Puts an R startup script into the user preferences.
555
   */
556
  private void putStartupScript( final String s ) {
557
    try {
558
      getPreferences().put( PERSIST_R_STARTUP, s );
559
    } catch( final Exception ex ) {
560
      getNotifier().notify( ex );
561
    }
562
  }
563
564
  //---- Help actions -------------------------------------------------------
565
  private void helpAbout() {
566
    Alert alert = new Alert( AlertType.INFORMATION );
567
    alert.setTitle( get( "Dialog.about.title" ) );
568
    alert.setHeaderText( get( "Dialog.about.header" ) );
569
    alert.setContentText( get( "Dialog.about.content" ) );
570
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
571
    alert.initOwner( getWindow() );
572
573
    alert.showAndWait();
574
  }
575
576
  //---- Convenience accessors ----------------------------------------------
577
  private float getFloat( final String key, final float defaultValue ) {
578
    return getPreferences().getFloat( key, defaultValue );
579
  }
580
581
  private Preferences getPreferences() {
582
    return getOptions().getState();
583
  }
584
585
  protected Scene getScene() {
586
    if( this.scene == null ) {
587
      this.scene = createScene();
588
    }
589
590
    return this.scene;
591
  }
592
593
  public Window getWindow() {
594
    return getScene().getWindow();
595
  }
596
597
  private MarkdownEditorPane getActiveEditor() {
598
    final EditorPane pane = getActiveFileEditor().getEditorPane();
599
600
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
601
  }
602
603
  private FileEditorTab getActiveFileEditor() {
604
    return getFileEditorPane().getActiveFileEditor();
605
  }
606
607
  //---- Member accessors ---------------------------------------------------
608
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
609
    this.processors = map;
610
  }
611
612
  private Map<FileEditorTab, Processor<String>> getProcessors() {
613
    if( this.processors == null ) {
614
      setProcessors( new HashMap<>() );
615
    }
616
617
    return this.processors;
618
  }
619
620
  private FileEditorTabPane getFileEditorPane() {
621
    if( this.fileEditorPane == null ) {
622
      this.fileEditorPane = createFileEditorPane();
623
    }
624
625
    return this.fileEditorPane;
626
  }
627
628
  private HTMLPreviewPane getPreviewPane() {
629
    if( this.previewPane == null ) {
630
      this.previewPane = createPreviewPane();
631
    }
632
633
    return this.previewPane;
634
  }
635
636
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
637
    this.definitionSource = definitionSource;
638
  }
639
640
  private DefinitionSource getDefinitionSource() {
641
    if( this.definitionSource == null ) {
642
      this.definitionSource = new EmptyDefinitionSource();
643
    }
644
645
    return this.definitionSource;
646
  }
647
648
  private DefinitionPane getDefinitionPane() {
649
    if( this.definitionPane == null ) {
650
      this.definitionPane = createDefinitionPane();
651
    }
652
653
    return this.definitionPane;
654
  }
655
656
  private Options getOptions() {
657
    return this.options;
658
  }
659
660
  private Snitch getSnitch() {
661
    return this.snitch;
662
  }
663
664
  private Notifier getNotifier() {
665
    return this.notifier;
666
  }
667
668
  public void setMenuBar( final MenuBar menuBar ) {
669
    this.menuBar = menuBar;
670
  }
671
672
  public MenuBar getMenuBar() {
673
    return this.menuBar;
674
  }
675
676
  private Text getLineNumberText() {
677
    if( this.lineNumberText == null ) {
678
      this.lineNumberText = createLineNumberText();
679
    }
680
681
    return this.lineNumberText;
682
  }
683
684
  private synchronized StatusBar getStatusBar() {
685
    if( this.statusBar == null ) {
686
      this.statusBar = createStatusBar();
687
    }
688
689
    return this.statusBar;
690
  }
691
692
  private TextField getFindTextField() {
693
    if( this.findTextField == null ) {
694
      this.findTextField = createFindTextField();
695
    }
696
697
    return this.findTextField;
698
  }
699
700
  //---- Member creators ----------------------------------------------------
701
  /**
702
   * Factory to create processors that are suited to different file types.
703
   *
704
   * @param tab The tab that is subjected to processing.
705
   *
706
   * @return A processor suited to the file type specified by the tab's path.
707
   */
708
  private Processor<String> createProcessor( final FileEditorTab tab ) {
709
    return createProcessorFactory().createProcessor( tab );
710
  }
711
712
  private ProcessorFactory createProcessorFactory() {
713
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
714
  }
715
716
  private DefinitionSource createDefinitionSource( final String path ) {
717
    final DefinitionSource ds
718
      = createDefinitionFactory().createDefinitionSource( path );
719
720
    if( ds instanceof FileDefinitionSource ) {
721
      try {
722
        getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
723
      } catch( final IOException ex ) {
724
        error( ex );
725
      }
726
    }
727
728
    return ds;
729
  }
730
731
  private TextField createFindTextField() {
732
    return new TextField();
733
  }
734
735
  /**
736
   * Create an editor pane to hold file editor tabs.
737
   *
738
   * @return A new instance, never null.
739
   */
740
  private FileEditorTabPane createFileEditorPane() {
741
    return new FileEditorTabPane();
742
  }
743
744
  private HTMLPreviewPane createPreviewPane() {
745
    return new HTMLPreviewPane();
746
  }
747
748
  private DefinitionPane createDefinitionPane() {
749
    return new DefinitionPane( getTreeView() );
750
  }
751
752
  private DefinitionFactory createDefinitionFactory() {
753
    return new DefinitionFactory();
754
  }
755
756
  private StatusBar createStatusBar() {
757
    return new StatusBar();
758
  }
759
760
  private Scene createScene() {
761
    final SplitPane splitPane = new SplitPane(
762
      getDefinitionPane().getNode(),
763
      getFileEditorPane().getNode(),
764
      getPreviewPane().getNode() );
765
766
    splitPane.setDividerPositions(
767
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
768
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
769
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
770
771
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
772
    final BorderPane borderPane = new BorderPane();
773
    borderPane.setPrefSize( 1024, 800 );
774
    borderPane.setTop( createMenuBar() );
775
    borderPane.setBottom( getStatusBar() );
776
    borderPane.setCenter( splitPane );
777
778
    final VBox box = new VBox();
779
    box.setAlignment( Pos.BASELINE_CENTER );
780
    box.getChildren().add( getLineNumberText() );
781
    getStatusBar().getRightItems().add( box );
782
783
    return new Scene( borderPane );
784
  }
785
786
  private Text createLineNumberText() {
787
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
788
  }
789
790
  private Node createMenuBar() {
791
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
792
793
    // File actions
794
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
795
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
796
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
797
    final Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
798
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
799
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
800
    final Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
801
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
802
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
803
804
    // Edit actions
805
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
806
      e -> getActiveEditor().undo(),
807
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
808
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
809
      e -> getActiveEditor().redo(),
810
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
811
    final Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
812
      e -> find(),
813
      activeFileEditorIsNull );
814
    final Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
815
      e -> getActiveEditor().replace(),
816
      activeFileEditorIsNull );
817
    final Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
818
      e -> findNext(),
819
      activeFileEditorIsNull );
820
    final Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
821
      e -> getActiveEditor().findPrevious(),
822
      activeFileEditorIsNull );
823
824
    // Insert actions
825
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
826
      e -> getActiveEditor().surroundSelection( "**", "**" ),
827
      activeFileEditorIsNull );
828
    final Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
829
      e -> getActiveEditor().surroundSelection( "*", "*" ),
830
      activeFileEditorIsNull );
831
    final Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
832
      e -> getActiveEditor().surroundSelection( "^", "^" ),
833
      activeFileEditorIsNull );
834
    final Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
835
      e -> getActiveEditor().surroundSelection( "~", "~" ),
836
      activeFileEditorIsNull );
837
    final Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
838
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
839
      activeFileEditorIsNull );
840
    final Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
841
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
842
      activeFileEditorIsNull );
843
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
844
      e -> getActiveEditor().surroundSelection( "`", "`" ),
845
      activeFileEditorIsNull );
846
    final Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
847
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
848
      activeFileEditorIsNull );
849
850
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
851
      e -> getActiveEditor().insertLink(),
852
      activeFileEditorIsNull );
853
    final Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
854
      e -> getActiveEditor().insertImage(),
855
      activeFileEditorIsNull );
856
857
    final Action[] headers = new Action[ 6 ];
858
859
    // Insert header actions (H1 ... H6)
860
    for( int i = 1; i <= 6; i++ ) {
861
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
862
      final String markup = String.format( "%n%n%s ", hashes );
863
      final String text = get( "Main.menu.insert.header_" + i );
864
      final String accelerator = "Shortcut+" + i;
865
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
866
867
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
868
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
869
        activeFileEditorIsNull );
870
    }
871
872
    final Action insertUnorderedListAction = new Action(
873
      get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
874
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
875
      activeFileEditorIsNull );
876
    final Action insertOrderedListAction = new Action(
877
      get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
878
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
879
      activeFileEditorIsNull );
880
    final Action insertHorizontalRuleAction = new Action(
881
      get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
882
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
883
      activeFileEditorIsNull );
884
885
    // Tools actions
886
    final Action toolsScriptAction = new Action(
887
      get( "Main.menu.tools.script" ), null, null, e -> toolsScript() );
888
889
    // Help actions
890
    final Action helpAboutAction = new Action(
891
      get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
892
893
    //---- MenuBar ----
894
    final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
895
      fileNewAction,
896
      fileOpenAction,
897
      null,
898
      fileCloseAction,
899
      fileCloseAllAction,
900
      null,
901
      fileSaveAction,
524
  private void fileSaveAs() {
525
    final FileEditorTab editor = getActiveFileEditor();
526
    getFileEditorPane().saveEditorAs( editor );
527
    getProcessors().remove( editor );
528
529
    try {
530
      refreshSelectedTab( editor );
531
    } catch( final Exception ex ) {
532
      getNotifier().notify( ex );
533
    }
534
  }
535
536
  private void fileSaveAll() {
537
    getFileEditorPane().saveAllEditors();
538
  }
539
540
  private void fileExit() {
541
    final Window window = getWindow();
542
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
543
  }
544
545
  //---- Tools actions
546
  private void toolsScript() {
547
    final String script = getStartupScript();
548
549
    final RScriptDialog dialog = new RScriptDialog(
550
      getWindow(), "Dialog.rScript.title", script );
551
    final Optional<String> result = dialog.showAndWait();
552
553
    result.ifPresent( (String s) -> {
554
      putStartupScript( s );
555
    } );
556
  }
557
558
  /**
559
   * Gets the R startup script from the user preferences.
560
   */
561
  private String getStartupScript() {
562
    return getPreferences().get( PERSIST_R_STARTUP, "" );
563
  }
564
565
  /**
566
   * Puts an R startup script into the user preferences.
567
   */
568
  private void putStartupScript( final String s ) {
569
    try {
570
      getPreferences().put( PERSIST_R_STARTUP, s );
571
    } catch( final Exception ex ) {
572
      getNotifier().notify( ex );
573
    }
574
  }
575
576
  //---- Help actions -------------------------------------------------------
577
  private void helpAbout() {
578
    Alert alert = new Alert( AlertType.INFORMATION );
579
    alert.setTitle( get( "Dialog.about.title" ) );
580
    alert.setHeaderText( get( "Dialog.about.header" ) );
581
    alert.setContentText( get( "Dialog.about.content" ) );
582
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
583
    alert.initOwner( getWindow() );
584
585
    alert.showAndWait();
586
  }
587
588
  //---- Convenience accessors ----------------------------------------------
589
  private float getFloat( final String key, final float defaultValue ) {
590
    return getPreferences().getFloat( key, defaultValue );
591
  }
592
593
  private Preferences getPreferences() {
594
    return getOptions().getState();
595
  }
596
597
  protected Scene getScene() {
598
    if( this.scene == null ) {
599
      this.scene = createScene();
600
    }
601
602
    return this.scene;
603
  }
604
605
  public Window getWindow() {
606
    return getScene().getWindow();
607
  }
608
609
  private MarkdownEditorPane getActiveEditor() {
610
    final EditorPane pane = getActiveFileEditor().getEditorPane();
611
612
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
613
  }
614
615
  private FileEditorTab getActiveFileEditor() {
616
    return getFileEditorPane().getActiveFileEditor();
617
  }
618
619
  //---- Member accessors ---------------------------------------------------
620
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
621
    this.processors = map;
622
  }
623
624
  private Map<FileEditorTab, Processor<String>> getProcessors() {
625
    if( this.processors == null ) {
626
      setProcessors( new HashMap<>() );
627
    }
628
629
    return this.processors;
630
  }
631
632
  private FileEditorTabPane getFileEditorPane() {
633
    if( this.fileEditorPane == null ) {
634
      this.fileEditorPane = createFileEditorPane();
635
    }
636
637
    return this.fileEditorPane;
638
  }
639
640
  private HTMLPreviewPane getPreviewPane() {
641
    if( this.previewPane == null ) {
642
      this.previewPane = createPreviewPane();
643
    }
644
645
    return this.previewPane;
646
  }
647
648
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
649
    this.definitionSource = definitionSource;
650
  }
651
652
  private DefinitionSource getDefinitionSource() {
653
    if( this.definitionSource == null ) {
654
      this.definitionSource = new EmptyDefinitionSource();
655
    }
656
657
    return this.definitionSource;
658
  }
659
660
  private DefinitionPane getDefinitionPane() {
661
    if( this.definitionPane == null ) {
662
      this.definitionPane = createDefinitionPane();
663
    }
664
665
    return this.definitionPane;
666
  }
667
668
  private Options getOptions() {
669
    return this.options;
670
  }
671
672
  private Snitch getSnitch() {
673
    return this.snitch;
674
  }
675
676
  private Notifier getNotifier() {
677
    return this.notifier;
678
  }
679
680
  public void setMenuBar( final MenuBar menuBar ) {
681
    this.menuBar = menuBar;
682
  }
683
684
  public MenuBar getMenuBar() {
685
    return this.menuBar;
686
  }
687
688
  private Text getLineNumberText() {
689
    if( this.lineNumberText == null ) {
690
      this.lineNumberText = createLineNumberText();
691
    }
692
693
    return this.lineNumberText;
694
  }
695
696
  private synchronized StatusBar getStatusBar() {
697
    if( this.statusBar == null ) {
698
      this.statusBar = createStatusBar();
699
    }
700
701
    return this.statusBar;
702
  }
703
704
  private TextField getFindTextField() {
705
    if( this.findTextField == null ) {
706
      this.findTextField = createFindTextField();
707
    }
708
709
    return this.findTextField;
710
  }
711
712
  //---- Member creators ----------------------------------------------------
713
  /**
714
   * Factory to create processors that are suited to different file types.
715
   *
716
   * @param tab The tab that is subjected to processing.
717
   *
718
   * @return A processor suited to the file type specified by the tab's path.
719
   */
720
  private Processor<String> createProcessor( final FileEditorTab tab ) {
721
    return createProcessorFactory().createProcessor( tab );
722
  }
723
724
  private ProcessorFactory createProcessorFactory() {
725
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
726
  }
727
728
  private DefinitionSource createDefinitionSource( final String path ) {
729
    final DefinitionSource ds
730
      = createDefinitionFactory().createDefinitionSource( path );
731
732
    if( ds instanceof FileDefinitionSource ) {
733
      try {
734
        getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
735
      } catch( final IOException ex ) {
736
        error( ex );
737
      }
738
    }
739
740
    return ds;
741
  }
742
743
  private TextField createFindTextField() {
744
    return new TextField();
745
  }
746
747
  /**
748
   * Create an editor pane to hold file editor tabs.
749
   *
750
   * @return A new instance, never null.
751
   */
752
  private FileEditorTabPane createFileEditorPane() {
753
    return new FileEditorTabPane();
754
  }
755
756
  private HTMLPreviewPane createPreviewPane() {
757
    return new HTMLPreviewPane();
758
  }
759
760
  private DefinitionPane createDefinitionPane() {
761
    return new DefinitionPane( getTreeView() );
762
  }
763
764
  private DefinitionFactory createDefinitionFactory() {
765
    return new DefinitionFactory();
766
  }
767
768
  private StatusBar createStatusBar() {
769
    return new StatusBar();
770
  }
771
772
  private Scene createScene() {
773
    final SplitPane splitPane = new SplitPane(
774
      getDefinitionPane().getNode(),
775
      getFileEditorPane().getNode(),
776
      getPreviewPane().getNode() );
777
778
    splitPane.setDividerPositions(
779
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
780
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
781
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
782
783
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
784
    final BorderPane borderPane = new BorderPane();
785
    borderPane.setPrefSize( 1024, 800 );
786
    borderPane.setTop( createMenuBar() );
787
    borderPane.setBottom( getStatusBar() );
788
    borderPane.setCenter( splitPane );
789
790
    final VBox box = new VBox();
791
    box.setAlignment( Pos.BASELINE_CENTER );
792
    box.getChildren().add( getLineNumberText() );
793
    getStatusBar().getRightItems().add( box );
794
795
    return new Scene( borderPane );
796
  }
797
798
  private Text createLineNumberText() {
799
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
800
  }
801
802
  private Node createMenuBar() {
803
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
804
805
    // File actions
806
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
807
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
808
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
809
    final Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
810
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
811
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
812
    final Action fileSaveAsAction = new Action( Messages.get( "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(), activeFileEditorIsNull );
813
    final Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
814
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
815
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
816
817
    // Edit actions
818
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
819
      e -> getActiveEditor().undo(),
820
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
821
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
822
      e -> getActiveEditor().redo(),
823
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
824
    final Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
825
      e -> find(),
826
      activeFileEditorIsNull );
827
    final Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
828
      e -> getActiveEditor().replace(),
829
      activeFileEditorIsNull );
830
    final Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
831
      e -> findNext(),
832
      activeFileEditorIsNull );
833
    final Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
834
      e -> getActiveEditor().findPrevious(),
835
      activeFileEditorIsNull );
836
837
    // Insert actions
838
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
839
      e -> getActiveEditor().surroundSelection( "**", "**" ),
840
      activeFileEditorIsNull );
841
    final Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
842
      e -> getActiveEditor().surroundSelection( "*", "*" ),
843
      activeFileEditorIsNull );
844
    final Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
845
      e -> getActiveEditor().surroundSelection( "^", "^" ),
846
      activeFileEditorIsNull );
847
    final Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
848
      e -> getActiveEditor().surroundSelection( "~", "~" ),
849
      activeFileEditorIsNull );
850
    final Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
851
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
852
      activeFileEditorIsNull );
853
    final Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
854
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
855
      activeFileEditorIsNull );
856
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
857
      e -> getActiveEditor().surroundSelection( "`", "`" ),
858
      activeFileEditorIsNull );
859
    final Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
860
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
861
      activeFileEditorIsNull );
862
863
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
864
      e -> getActiveEditor().insertLink(),
865
      activeFileEditorIsNull );
866
    final Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
867
      e -> getActiveEditor().insertImage(),
868
      activeFileEditorIsNull );
869
870
    final Action[] headers = new Action[ 6 ];
871
872
    // Insert header actions (H1 ... H6)
873
    for( int i = 1; i <= 6; i++ ) {
874
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
875
      final String markup = String.format( "%n%n%s ", hashes );
876
      final String text = get( "Main.menu.insert.header_" + i );
877
      final String accelerator = "Shortcut+" + i;
878
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
879
880
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
881
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
882
        activeFileEditorIsNull );
883
    }
884
885
    final Action insertUnorderedListAction = new Action(
886
      get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
887
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
888
      activeFileEditorIsNull );
889
    final Action insertOrderedListAction = new Action(
890
      get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
891
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
892
      activeFileEditorIsNull );
893
    final Action insertHorizontalRuleAction = new Action(
894
      get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
895
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
896
      activeFileEditorIsNull );
897
898
    // Tools actions
899
    final Action toolsScriptAction = new Action(
900
      get( "Main.menu.tools.script" ), null, null, e -> toolsScript() );
901
902
    // Help actions
903
    final Action helpAboutAction = new Action(
904
      get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
905
906
    //---- MenuBar ----
907
    final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
908
      fileNewAction,
909
      fileOpenAction,
910
      null,
911
      fileCloseAction,
912
      fileCloseAllAction,
913
      null,
914
      fileSaveAction,
915
      fileSaveAsAction,
902916
      fileSaveAllAction,
903917
      null,
M src/main/java/com/scrivenvar/definition/TextFieldTreeCell.java
6464
    removeItem.setOnAction( (ActionEvent e) -> {
6565
      final TreeItem c = getTreeItem();
66
      boolean remove = c.getParent().getChildren().remove( c );
67
      System.out.println( "Remove" );
66
      c.getParent().getChildren().remove( c );
6867
    } );
6968
M src/main/java/com/scrivenvar/definition/yaml/YamlParser.java
171171
   */
172172
  private void resolve(
173
    final JsonNode rootNode, final String path, final Map<String, String> map ) {
173
    final JsonNode rootNode,
174
    final String path,
175
    final Map<String, String> map ) {
174176
175177
    if( rootNode != null ) {
M src/main/java/com/scrivenvar/dialogs/RScriptDialog.java
4747
4848
  private TextArea scriptArea;
49
  private String originalText = "";
4950
5051
  public RScriptDialog(
5152
    final Window parent, final String title, final String script ) {
5253
    super( parent, title );
54
    setOriginalText( script );
5355
    getScriptArea().setText( script );
5456
  }
...
7678
7779
    setResultConverter( dialogButton -> {
78
      return dialogButton == OK ? textArea.getText() : "";
80
      return dialogButton == OK ? textArea.getText() : getOriginalText();
7981
    } );
8082
  }
8183
8284
  private TextArea getScriptArea() {
8385
    if( this.scriptArea == null ) {
8486
      this.scriptArea = new TextArea();
8587
    }
8688
8789
    return this.scriptArea;
90
  }
91
92
  private String getOriginalText() {
93
    return this.originalText;
94
  }
95
96
  private void setOriginalText( final String originalText ) {
97
    this.originalText = originalText;
8898
  }
8999
}
M src/main/java/com/scrivenvar/editors/EditorPane.java
7474
    getUndoManager().redo();
7575
  }
76
  
76
77
  /**
78
   * TOD: Implement this.
79
   */
7780
  public void replace() {
78
    System.out.println( "replace" );
7981
  }
80
  
82
83
  /**
84
   * TOD: Implement this.
85
   */
8186
  public void findPrevious() {
82
    System.out.println( "find previous" );
8387
  }
8488
M src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
4848
import org.fxmisc.richtext.StyleClassedTextArea;
4949
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
50
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
51
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
52
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
5350
5451
/**
...
7471
7572
    addEventListener( keyPressed( ENTER ), this::enterPressed );
76
77
    // TODO: Wait for implementation that allows cutting lines, not paragraphs.
78
//    addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine );
7973
  }
8074
A src/main/java/com/scrivenvar/processors/IdentityProcessor.java
1
/*
2
 * Copyright 2017 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
/**
31
 * This is the default processor used when an unknown filename extension is
32
 * encountered.
33
 *
34
 * @author White Magic Software, Ltd.
35
 */
36
public class IdentityProcessor extends AbstractProcessor<String> {
37
38
  /**
39
   * Passes the link to the super constructor.
40
   * 
41
   * @param link The next processor in the chain to use for text processing.
42
   */
43
  public IdentityProcessor( final Processor<String> link ) {
44
    super( link );
45
  }
46
47
  /**
48
   * Returns the given string, modified with "pre" tags.
49
   *
50
   * @param t The string to return, enclosed in "pre" tags.
51
   *
52
   * @return t
53
   */
54
  @Override
55
  public String processLink( final String t ) {
56
    final StringBuilder result = new StringBuilder( t.length() + 16 );
57
    
58
    return result.append( "<pre>" ).append( t ).append( "</pre>" ).toString();
59
  }
60
}
161
M src/main/java/com/scrivenvar/processors/ProcessorFactory.java
7272
  public Processor<String> createProcessor( final FileEditorTab tab ) {
7373
    final Path path = tab.getPath();
74
    Processor<String> processor = null;
74
    final Processor<String> processor;
7575
7676
    switch( lookup( path ) ) {
...
9292
9393
      default:
94
        unknownExtension( path );
94
        processor = createIdentityProcessor( tab );
9595
        break;
9696
    }
...
124124
125125
    return mpp;
126
  }
127
  
128
  protected Processor<String> createIdentityProcessor( final FileEditorTab tab ) {
129
    final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() );
130
    final Processor<String> ip = new IdentityProcessor( hpp );
131
    
132
    return ip;
126133
  }
127134
A src/main/r/README.md
1
# R Scripts
2
3
These R scripts illustrate how R can be used within an application to perform calculations using variables. Authors are free to write their own scripts, of course. These scripts serve as an example of how to automate certain tasks while writing.
4
5
## Configuration
6
7
Configure the editor to use the R scripts as follows:
8
9
1. Copy the R scripts into same directory as your Markdown files.
10
1. Start the editor.
11
1. Click **Tools → R Script**.
12
1. Copy and paste the following:
13
14
        assign( 'anchor', as.Date( '$date.anchor$', format='%Y-%m-%d' ), envir = .GlobalEnv );
15
        setwd( '$application.r.working.directory$' );
16
        source( 'pluralize.R' );
17
        source( 'csv.R' );
18
        source( 'conversion.R' );
19
20
1. Click **File → New** to create a new file.
21
1. Click **File → Save As** to set a filename.
22
1. Set **Name** to: `variables.yaml`
23
1. Click **OK**.
24
1. Paste the following definitions:
25
26
       date:
27
         anchor: 2017-01-01
28
       editor:
29
         examples:
30
           season: 2017-09-02
31
           math:
32
             x: 1
33
             y: $editor.examples.math.x$ + 1
34
             z: $editor.examples.math.y$ + 1
35
           name:
36
             given: Josephene
37
38
1. Save and close the file.
39
1. Click **File → Open**
40
1. Change **Markdown Files** to **Definition Files**.
41
1. Select `variables.yaml`.
42
1. Click **Open**.
43
44
R functionality is configured.
45
46
## Definitions
47
48
The variables definitions within `variables.yaml` are available to R using the R syntax. An additional variable, `application.r.working.directory` is added to the list of variables. The value is set to the working directory of the file being edited. Hover the mouse cursor over the file tab in the editor to see the full path to the file.
49
50
## Examples
51
52
This section demonstrates how to use the R functions when editing. Complete the following steps to begin:
53
54
1. Click **File → New** to create a new file.
55
1. Click **File → Save As** to set a filename.
56
1. Set **Name** to: `example.Rmd`
57
1. Click **OK**.
58
59
The examples are ready for use within the editor.
60
61
### Arithmetic
62
63
Type the following to perform a simple calculation:
64
65
    `r# 1+1`
66
67
The preview pane shows `2.0`.
68
69
### Functions
70
71
Call the [format](https://stat.ethz.ch/R-manual/R-devel/library/base/html/format.html) function to truncate unwanted decimal places as follows:
72
73
    `r# format(1+1,digits=1)`
74
75
The preview pane shows `2`.
76
77
### Pluralize
78
79
Many English words can be pluralized as follows:
80
81
    `r# pl('wolf',2)`
82
83
The preview pane shows `wolves`. The `pluralize.R` file contains a partial implementation of Damian Conway's algorithmic approach to English pluralization.
84
85
### Chicago Manual of Style
86
87
Apply the Chicago Manual of Style for words less than one-hundred as follows:
88
89
       `r# cms(1)` `r# cms(99)` `r# cms(101)`
90
91
The preview pane shows numbers written out as `one` and `ninety-nine`, followed by the digits 101.
92
93
### Data Import
94
95
Import and display information from a CSV file as follows:
96
97
1. Click **File → New** to create a new file.
98
1. Click **File → Save As** to rename the file.
99
1. Set the filename to: `data.csv`
100
1. Paste the following into `data.csv`:
101
102
        Animal,Quantity,Country
103
        Aardwolf,1,Africa
104
        Keel-billed toucan,1,Belize
105
        Beaver,2,Canada
106
        Mute swan,3,Denmark
107
        Lion,5,Ethiopia
108
        Brown bear,8,Finland
109
        Dolphin,13,Greece
110
        Turul,21,Hungary
111
        Gyrfalcon,34,Iceland
112
        Red-billed streamertail,55,Jamaica
113
114
1. Click the `example.Rmd` tab.
115
1. Type the following:
116
117
       `r# csv2md('data.csv',total=F)`
118
119
1. Type the following to sum all numeric columns, use:
120
121
       `r# csv2md('data.csv')`
122
123
This imports the data from an external file and formats the information into a table, automatically. Update the data as follows:
124
125
1. Click the `data.csv` tab to edit the data.
126
1. Change the data by adding a new row.
127
1. Save the file.
128
1. Click the `example.Rmd` tab.
129
130
The preview pane shows the revised contents.
131
132
### Elapsed Time
133
134
The duration of a timeline, given in numbers of days, can be computed into English as follows:
135
136
    `r# elapsed(1,1)`
137
138
The preview pane shows `same day`. Change the expression to:
139
140
    `r# elapsed(1,2)`
141
142
The preview pane shows `one day`. Change the expression to:
143
144
    `r# elapsed(1,112358)`
145
146
The preview pane shows `307 years, seven months, and sixteen days`, combined using the Chicago Manual of Style, the pluralization function, and a [serial comma](https://www.behance.net/gallery/19417363/The-Oxford-Comma).
147
148
### Variable Syntax
149
150
The syntax for a variable changes when using an R Markdown file (denoted by the `.Rmd` filename extension), as opposed to a regular Markdown file (`.md`). Return to the example file and type the following:
151
152
    `r# v$date$anchor`
153
154
The preview pane shows the date.
155
156
### Autocomplete
157
158
Automatically insert a variable reference into the text as follows:
159
160
1. Type: `Jos`
161
    * Note the capital letter, matches are case sensitive.
162
1. Hold down the `Control` key.
163
1. Tap the `Spacebar`
164
165
The editor shows:
166
167
    `r#x( v$editor$examples$name$given )`
168
169
The preview pane shows:
170
171
    Josephine
172
173
Here, the `x` function evaluates its parameter as an expression. This allows variables to include expressions in their definition.
174
175
### Variable Definition Expressions
176
177
Definition file variables are have the ability to reference other definitions. Try the following:
178
179
    `r#x( v$editor$examples$math$x )`
180
    `r#x( v$editor$examples$math$y )`
181
    `r#x( v$editor$examples$math$z )`
182
183
The preview pane shows:
184
185
    x = 1.0; y = 2.0; z = 3.0
186
187
### Case
188
189
Ensure words begin with a lowercase letter as follows:
190
191
    `r#lc( v$editor$examples$name$given )`
192
193
The preview pane shows:
194
195
    josephine
196
197
Similarly, ensure an uppercase letter as follows:
198
199
    `r#uc( 'hello, world!' )`
200
201
The preview pane shows:
202
203
    Hello, world!
204
205
### Month
206
207
Display the month name given a month number as follows:
208
209
    `r# month( 1 )`
210
211
The preview pane shows:
212
213
    January
214
215
## Summary
216
217
Authors can inline R statements into documents, directly, so long as those statements generate text. Plots, graphs, and images must be referenced as external image files or URLs.
1218
A src/main/r/conversion.R
1
# ########################################################################
2
#
3
# Substitute R expressions in a document with their evaluated value. The
4
# anchor variable must be set for functions that use relative dates.
5
#
6
# ########################################################################
7
8
# Evaluates an expression; writes s if there is no expression.
9
x <- function( s ) {
10
  return(
11
    tryCatch({
12
      r = eval( parse( text=s ) )
13
14
      # If the result isn't primitive, then it was probably parsed into
15
      # an unprintable object (e.g., "gray" becomes a colour). In those
16
      # cases, return the original text string. Otherwise, an atomic
17
      # value means a primitive type (string, integer, etc.) that can be
18
      # written directly into the document.
19
      #
20
      # See: http://stackoverflow.com/a/19501276/59087
21
      if( is.atomic( r ) ) {
22
        r
23
      }
24
      else {
25
        s
26
      }
27
    },
28
    warning = function( w ) {
29
      s
30
    },
31
    error = function( e ) {
32
      s
33
    })
34
  )
35
}
36
37
# Returns a date offset by a given number of days, relative to the given
38
# date (d). This does not use the anchor, but is used to get the anchor's
39
# value as a date.
40
when <- function( d, n = 0, format = "%Y-%m-%d" ) {
41
  as.Date( d, format = format ) + x( n )
42
}
43
44
# Full date (s) offset by an optional number of days before or after.
45
# This will remove leading zeros (applying leading spaces instead, which
46
# are ignored by any worthwhile typesetting engine).
47
annal <- function( days = 0, format = "%Y-%m-%d", oformat = "%B %d, %Y" ) {
48
  format( when( anchor, days ), format = oformat )
49
}
50
51
# Extracts the year from a date string.
52
year <- function( days = 0, format = "%Y-%m-%d" ) {
53
  annal( days, format, "%Y" )
54
}
55
56
# Day of the week (in days since the anchor date).
57
weekday <- function( n ) {
58
  weekdays( when( anchor, n ) )
59
}
60
61
# String concatenate function alias because paste0 is a terrible name.
62
concat <- paste0
63
64
# Translates a number from digits to words using Chicago Manual of Style.
65
# This does not translate numbers greater than one hundred. If ordinal
66
# is TRUE, this will return the ordinal name. This will not produce ordinals
67
# for numbers greater than 100
68
cms <- function( n, ordinal = FALSE ) {
69
  n <- x( n )
70
71
  # We're done here.
72
  if( n == 0 ) {
73
    if( ordinal ) {
74
      return( "zeroth" )
75
    }
76
77
    return( "zero" )
78
  }
79
80
  # Concatenate this a little later.
81
  if( n < 0 ) {
82
    result = "negative "
83
    n = abs( n )
84
  }
85
86
  # Do not spell out numbers greater than one hundred.
87
  if( n > 100 ) {
88
    # Comma-separated numbers.
89
    return( format( n, big.mark=",", trim=TRUE, scientific=FALSE ) )
90
  }
91
92
  # Don't go beyond 100.
93
  if( n == 100 ) {
94
    if( ordinal ) {
95
      return( "one hundredth" )
96
    }
97
98
    return( "one hundred" )
99
  }
100
101
  # Samuel Langhorne Clemens noted English has too many exceptions.
102
  small = c(
103
    "one", "two", "three", "four", "five",
104
    "six", "seven", "eight", "nine", "ten",
105
    "eleven", "twelve", "thirteen", "fourteen", "fifteen",
106
    "sixteen", "seventeen", "eighteen", "nineteen"
107
  )
108
109
  ord_small = c(
110
    "first", "second", "third", "fourth", "fifth",
111
    "sixth", "seventh", "eighth", "ninth", "tenth",
112
    "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth",
113
    "sixteenth", "seventeenth", "eighteenth", "nineteenth", "twentieth"
114
  )
115
116
  # After this, the number (n) is between 20 and 99.
117
  if( n < 20 ) {
118
    if( ordinal ) {
119
      return( .subset( ord_small, n %% 100 ) )
120
    }
121
122
    return( .subset( small, n %% 100 ) )
123
  }
124
125
  tens = c( "",
126
    "twenty", "thirty", "forty", "fifty",
127
    "sixty", "seventy", "eighty", "ninety"
128
  )
129
130
  ord_tens = c( "",
131
    "twentieth", "thirtieth", "fortieth", "fiftieth",
132
    "sixtieth", "seventieth", "eightieth", "ninetieth"
133
  )
134
135
  ones_index = n %% 10
136
  n = n %/% 10
137
138
  # No number in the ones column, so the number must be a multiple of ten.
139
  if( ones_index == 0 ) {
140
    if( ordinal ) {
141
      return( .subset( ord_tens, n ) )
142
    }
143
144
    return( .subset( tens, n ) )
145
  }
146
147
  # Find the value from the ones column.
148
  if( ordinal ) {
149
    unit_1 = .subset( ord_small, ones_index )
150
  }
151
  else {
152
    unit_1 = .subset( small, ones_index )
153
  }
154
155
  # Find the tens column.
156
  unit_10 = .subset( tens, n )
157
158
  # Hyphenate the tens and the ones together.
159
  concat( unit_10, concat( "-", unit_1 ) )
160
}
161
162
# Returns a human-readable string that provides the elapsed time between
163
# two numbers in terms of years, months, and days. If any unit value is zero,
164
# the unit is not included. The words (year, month, day) are pluralized
165
# according to English grammar. The numbers are written out according to
166
# Chicago Manual of Style. This applies the serial comma.
167
#
168
# Both numbers are offsets relative to the anchor date.
169
#
170
# If all unit values are zero, this returns s ("same day" by default).
171
#
172
# If the start date (began) is greater than end date (ended), the dates are
173
# swapped before calculations are performed. This allows any two dates
174
# to be compared and positive unit values are always returned.
175
#
176
elapsed <- function( began, ended, s = "same day" ) {
177
  began = when( anchor, began )
178
  ended = when( anchor, ended )
179
180
  # Swap the dates if the end date comes before the start date.
181
  if( as.integer( ended - began ) < 0 ) {
182
    tempd = began
183
    began = ended
184
    ended = tempd
185
  }
186
187
  # Calculate number of elapsed years.
188
  years = length( seq( from = began, to = ended, by = 'year' ) ) - 1
189
190
  # Move the start date up by the number of elapsed years.
191
  if( years > 0 ) {
192
    began = seq( began, length = 2, by = concat( years, " years" ) )[2]
193
    years = pl.numeric( "year", years )
194
  }
195
  else {
196
    # Zero years.
197
    years = ""
198
  }
199
200
  # Calculate number of elapsed months, excluding years.
201
  months = length( seq( from = began, to = ended, by = 'month' ) ) - 1
202
203
  # Move the start date up by the number of elapsed months
204
  if( months > 0 ) {
205
    began = seq( began, length = 2, by = concat( months, " months" ) )[2]
206
    months = pl.numeric( "month", months )
207
  }
208
  else {
209
    # Zero months
210
    months = ""
211
  }
212
213
  # Calculate number of elapsed days, excluding months and years.
214
  days = length( seq( from = began, to = ended, by = 'day' ) ) - 1
215
216
  if( days > 0 ) {
217
    days = pl.numeric( "day", days )
218
  }
219
  else {
220
    # Zero days
221
    days = ""
222
  }
223
224
  if( years <= 0 && months <= 0 && days <= 0 ) {
225
    return( s )
226
  }
227
228
  # Put them all in a vector, then remove the empty values.
229
  s <- c( years, months, days )
230
  s <- s[ s != "" ]
231
232
  r <- paste( s, collapse = ", " )
233
234
  # If all three items are present, replace the last comma with ", and".
235
  if( length( s ) > 2 ) {
236
    return( gsub( "(.*),", "\\1, and", r ) )
237
  }
238
239
  # Does nothing if no commas are present.
240
  gsub( "(.*),", "\\1 and", r )
241
}
242
243
# Returns the number (n) in English followed by the plural or singular
244
# form of the given string (s; resumably a noun), if applicable, according
245
# to English grammar. That is, pl.numeric( "wolf", 5 ) will return
246
# "five wolves".
247
pl.numeric <- function( s, n ) {
248
  concat( cms( n ), concat( " ", pluralize( s, n ) ) )
249
}
250
251
# Name of the season, starting with an capital letter.
252
season <- function( n, format = "%Y-%m-%d" ) {
253
  WS <- as.Date("2016-12-15", "%Y-%m-%d") # Winter Solstice
254
  SE <- as.Date("2016-03-15", "%Y-%m-%d") # Spring Equinox
255
  SS <- as.Date("2016-06-15", "%Y-%m-%d") # Summer Solstice
256
  AE <- as.Date("2016-09-15", "%Y-%m-%d") # Autumn Equinox
257
258
  d <- when( anchor, n )
259
  d <- as.Date( strftime( d, format="2016-%m-%d" ) )
260
261
  ifelse( d >= WS | d < SE, "Winter",
262
    ifelse( d >= SE & d < SS, "Spring",
263
      ifelse( d >= SS & d < AE, "Summer", "Autumn" )
264
    )
265
  )
266
}
267
268
# Converts the first letter in a string to lowercase
269
lc <- function( s ) {
270
  concat( tolower( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) )
271
}
272
273
# Converts the first letter in a string to uppercase
274
uc <- function( s ) {
275
  concat( toupper( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) )
276
}
277
278
# Returns the number of days between the given dates.
279
days <- function( d1, d2, format = "%Y-%m-%d" ) {
280
  dates = c( d1, d2 )
281
  dt = strptime( dates, format = format )
282
  as.integer( difftime( dates[2], dates[1], units = "days" ) )
283
}
284
285
# Returns the number of years elapsed.
286
years <- function( began, ended ) {
287
  began = when( anchor, began )
288
  ended = when( anchor, ended )
289
290
  # Swap the dates if the end date comes before the start date.
291
  if( as.integer( ended - began ) < 0 ) {
292
    tempd = began
293
    began = ended
294
    ended = tempd
295
  }
296
297
  # Calculate number of elapsed years.
298
  length( seq( from = began, to = ended, by = 'year' ) ) - 1
299
}
300
301
# Full name of the month, starting with a capital letter.
302
month <- function( n ) {
303
  # Faster than month.name[ x( n ) ]
304
  .subset( month.name, x( n ) )
305
}
306
307
money <- function( n ) {
308
  formatC( x( n ), format="d" )
309
}
1310
A src/main/r/csv.R
1
# ######################################################################
2
#
3
# Copyright 2016, White Magic Software, Ltd.
4
# 
5
# Permission is hereby granted, free of charge, to any person obtaining
6
# a copy of this software and associated documentation files (the
7
# "Software"), to deal in the Software without restriction, including
8
# without limitation the rights to use, copy, modify, merge, publish,
9
# distribute, sublicense, and/or sell copies of the Software, and to
10
# permit persons to whom the Software is furnished to do so, subject to
11
# the following conditions:
12
# 
13
# The above copyright notice and this permission notice shall be
14
# included in all copies or substantial portions of the Software.
15
# 
16
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
#
24
# ######################################################################
25
26
# ######################################################################
27
#
28
# Converts CSV to Markdown.
29
#
30
# ######################################################################
31
32
# Reads a CSV file and converts the contents to a Markdown table. The
33
# file must be in the working directory as specified by setwd.
34
#
35
# @param f The filename to convert.
36
# @param decimals Rounded decimal places (default 1).
37
# @param totals Include total sums (default TRUE).
38
# @param align Right-align numbers (default TRUE).
39
csv2md <- function( f, decimals = 1, totals = T, align = T ) {
40
  # Read the CVS data from the file; ensure strings become characters.
41
  df <- read.table( f, sep=',', header=T, stringsAsFactors=F )
42
43
  if( totals ) {
44
    # Determine what columns can be summed.
45
    number <- which( unlist( lapply( df, is.numeric ) ) )
46
47
    # Use colSums when more than one summable column exists.
48
    if( length( number ) > 1 ) {
49
      f.sum <- colSums
50
    }
51
    else {
52
      f.sum <- sum
53
    }
54
55
    # Calculate the sum of all the summable columns and insert the
56
    # results back into the data frame.
57
    df[ (nrow( df ) + 1), number ] <- f.sum( df[, number], na.rm=TRUE )
58
59
    # pluralize would be heavyweight here.
60
    if( length( number ) > 1 ) {
61
      t <- "**Totals**"
62
    }
63
    else {
64
      t <- "**Total**"
65
    }
66
67
    # Change the first column of the last line to "Total(s)".
68
    df[ nrow( df ), 1 ] <- t
69
70
    # Don't clutter the output with "NA" text.
71
    df[ is.na( df ) ] <- ""
72
  }
73
74
  if( align ) {
75
    is.char <- vapply( df, is.character, logical( 1 ) )
76
    dashes <- paste( ifelse( is.char, ':---', '---:' ), collapse='|' )
77
  }
78
  else {
79
    dashes <- paste( rep( '---', length( df ) ), collapse = '|')
80
  }
81
82
  # Create a Markdown version of the data frame.
83
  paste(
84
    paste( names( df ), collapse = '|'), '\n',
85
    dashes, '\n', 
86
    paste(
87
      Reduce( function( x, y ) {
88
          paste( x, format( y, digits = decimals ), sep = '|' )
89
        }, df
90
      ),
91
      collapse = '|\n', sep=''
92
    )
93
  )
94
}
95
196
A src/main/r/pluralize.R
1
# ######################################################################
2
#
3
# Copyright 2016, White Magic Software, Ltd.
4
# 
5
# Permission is hereby granted, free of charge, to any person obtaining
6
# a copy of this software and associated documentation files (the
7
# "Software"), to deal in the Software without restriction, including
8
# without limitation the rights to use, copy, modify, merge, publish,
9
# distribute, sublicense, and/or sell copies of the Software, and to
10
# permit persons to whom the Software is furnished to do so, subject to
11
# the following conditions:
12
# 
13
# The above copyright notice and this permission notice shall be
14
# included in all copies or substantial portions of the Software.
15
# 
16
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
#
24
# ######################################################################
25
26
# ######################################################################
27
#
28
# See Damian Conway's "An Algorithmic Approach to English Pluralization":
29
#   http://goo.gl/oRL4MP
30
# See Oliver Glerke's Evo Inflector: https://github.com/atteo/evo-inflector/
31
# See Shevek's Pluralizer: https://github.com/shevek/linguistics/
32
# See also: http://www.freevectors.net/assets/files/plural.txt
33
#
34
# ######################################################################
35
36
pluralize <- function( s, n ) {
37
  result <- s
38
39
  # Partial implementation of Conway's algorithm for nouns.
40
  if( n != 1 ) {
41
    if( pl.noninflective( s ) ||
42
        pl.suffix( "fish", s ) ||
43
        pl.suffix( "ois", s ) ||
44
        pl.suffix( "sheep", s ) ||
45
        pl.suffix( "deer", s ) ||
46
        pl.suffix( "pox", s ) ||
47
        pl.suffix( "[A-Z].*ese", s ) ||
48
        pl.suffix( "itis", s ) ) {
49
      # 1. Retain non-inflective user-mapped noun as is.
50
      # 2. Retain non-inflective plural as is.
51
      result <- s
52
    }
53
    else if( pl.is.irregular.pl( s ) ) {
54
      # 4. Change irregular plurals based on mapping.
55
      result <- pl.irregular.pl( s )
56
    }
57
    else if( pl.is.irregular.es( s ) ) {
58
      # x. From Shevek's Pluralizer
59
      result <- pl.inflect( s, "", "es" )
60
    }
61
    else if( pl.suffix( "man", s ) ) {
62
      # 5. For -man, change -an to -en
63
      result <- pl.inflect( s, "an", "en" )
64
    }
65
    else if( pl.suffix( "[lm]ouse", s ) ) {
66
      # 5. For [lm]ouse, change -ouse to -ice
67
      result <- pl.inflect( s, "ouse", "ice" )
68
    }
69
    else if( pl.suffix( "tooth", s ) ) {
70
      # 5. For -tooth, change -ooth to -eeth
71
      result <- pl.inflect( s, "ooth", "eeth" )
72
    }
73
    else if( pl.suffix( "goose", s ) ) {
74
      # 5. For -goose, change -oose to -eese
75
      result <- pl.inflect( s, "oose", "eese" )
76
    }
77
    else if( pl.suffix( "foot", s ) ) {
78
      # 5. For -foot, change -oot to -eet
79
      result <- pl.inflect( s, "oot", "eet" )
80
    }
81
    else if( pl.suffix( "zoon", s ) ) {
82
      # 5. For -zoon, change -on to -a
83
      result <- pl.inflect( s, "on", "a" )
84
    }
85
    else if( pl.suffix( "[csx]is", s ) ) {
86
      # 5. Change -cis, -sis, -xis to -es
87
      result <- pl.inflect( s, "is", "es" )
88
    }
89
    else if( pl.suffix( "([cs]h|ss)", s ) ) {
90
      # 8. Change -ch, -sh, -ss to -es
91
      result <- pl.inflect( s, "", "es" )
92
    }
93
    else if( pl.suffix( "([aeo]lf|[^d]eaf|arf)", s ) ) {
94
      # 9. Change -f to -ves
95
      result <- pl.inflect( s, "f", "ves" )
96
    }
97
    else if( pl.suffix( "[nlw]ife", s ) ) {
98
      # 9. Change -fe to -ves
99
      result <- pl.inflect( s, "fe", "ves" )
100
    }
101
    else if( pl.suffix( "([aeiou]y|[A-Z].*y)", s ) ) {
102
      # 10. Change -y to -ys.
103
      result <- pl.inflect( s, "", "s" )
104
    }
105
    else if( pl.suffix( "y", s ) ) {
106
      # 10. Change -y to -ies.
107
      result <- pl.inflect( s, "y", "ies" )
108
    }
109
    else {
110
      # 13. Default plural: add -s.
111
      result <- pl.inflect( s, "", "s" )
112
    }
113
  }
114
115
  result
116
}
117
118
# Pluralize s if n is not equal to 1.
119
pl <- function( s, n ) {
120
  pluralize( s, x( n ) )
121
}
122
123
# Returns the given string (s) with its suffix replaced by r.
124
pl.inflect <- function( s, suffix, r ) {
125
  gsub( paste( suffix, "$", sep="" ), r, s )
126
}
127
128
# Answers whether the given string (s) has the given ending.
129
pl.suffix <- function( ending, s ) {
130
  grepl( paste( ending, "$", sep="" ), s )
131
}
132
133
# Answers whether the given string (s) is a noninflective noun.
134
pl.noninflective <- function( s ) {
135
  v <- c(
136
    "aircraft", "Bhutanese", "bison", "bream", "breeches", "britches",
137
    "Burmese", "carp", "chassis", "Chinese", "clippers", "cod", "contretemps",
138
    "corps", "debris", "diabetes", "djinn", "eland", "elk", "flounder",
139
    "fracas", "gallows", "graffiti", "headquarters", "herpes", "high-jinks",
140
    "homework", "hovercraft", "innings", "jackanapes", "Japanese",
141
    "Lebanese", "mackerel", "means", "measles", "mews", "mumps", "news",
142
    "pincers", "pliers", "Portuguese", "proceedings", "rabies", "salmon",
143
    "scissors", "sea-bass", "Senegalese", "series", "shears", "Siamese",
144
    "Sinhalese", "spacecraft", "species", "swine", "trout", "tuna",
145
    "Vietnamese", "watercraft", "whiting", "wildebeest"
146
  )
147
148
  is.element( s, v )
149
}
150
151
# Answers whether the given string (s) is an irregular plural.
152
pl.is.irregular.pl <- function( s ) {
153
  # Could be refactored with pl.irregular.pl...
154
  v <- c(
155
    "beef", "brother", "child", "cow", "ephemeris", "genie", "money",
156
    "mongoose", "mythos", "octopus", "ox", "soliloquy", "trilby"
157
  )
158
159
  is.element( s, v )
160
}
161
162
# Call to pluralize an irregular noun. Only call after confirming
163
# the noun is irregular via pl.is.irregular.pl.
164
pl.irregular.pl <- function( s ) {
165
  v <- list(
166
    "beef" = "beefs",
167
    "brother" = "brothers",
168
    "child" = "children",
169
    "cow" = "cows",
170
    "ephemeris" = "ephemerides",
171
    "genie" = "genies",
172
    "money" = "moneys",
173
    "mongoose" = "mongooses",
174
    "mythos" = "mythoi",
175
    "octopus" = "octopuses",
176
    "ox" = "oxen",
177
    "soliloquy" = "soliloquies",
178
    "trilby" = "trilbys"
179
  )
180
181
  # Faster version of v[[ s ]]
182
  .subset2( v, s )
183
}
184
185
# Answers whether the given string (s) pluralizes with -es.
186
pl.is.irregular.es <- function( s ) {
187
  v <- c(
188
    "acropolis", "aegis", "alias", "asbestos", "bathos", "bias", "bronchitis",
189
    "bursitis", "caddis", "cannabis", "canvas", "chaos", "cosmos", "dais",
190
    "digitalis", "epidermis", "ethos", "eyas", "gas", "glottis", "hubris",
191
    "ibis", "lens", "mantis", "marquis", "metropolis", "pathos", "pelvis",
192
    "polis", "rhinoceros", "sassafrass", "trellis"
193
  )
194
195
  is.element( s, v )
196
}
197
1198
M src/main/resources/com/scrivenvar/messages.properties
3939
4040
Main.menu.file=_File
41
Main.menu.file.new=New
42
Main.menu.file.open=Open...
43
Main.menu.file.close=Close
41
Main.menu.file.new=_New
42
Main.menu.file.open=_Open...
43
Main.menu.file.close=_Close
4444
Main.menu.file.close_all=Close All
45
Main.menu.file.save=Save
46
Main.menu.file.save_all=Save All
47
Main.menu.file.exit=Exit
45
Main.menu.file.save=_Save
46
Main.menu.file.save_as=Save _As
47
Main.menu.file.save_all=Save A_ll
48
Main.menu.file.exit=E_xit
4849
4950
Main.menu.edit=_Edit