Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M CHANGES.md
11
# Change Log
22
3
## 0.6
4
5
- Bug fixes synchronized scrolling
6
- Added universal character encoding detection
7
- Removed options panel
8
- Decoupled Editor Tab and Preview Pane
9
310
## 0.5
411
M src/main/java/com/scrivenvar/FileEditorTab.java
2828
import com.scrivenvar.editor.EditorPane;
2929
import com.scrivenvar.editor.MarkdownEditorPane;
30
import com.scrivenvar.preview.HTMLPreviewPane;
31
import com.scrivenvar.service.Options;
32
import com.scrivenvar.service.events.AlertMessage;
33
import com.scrivenvar.service.events.AlertService;
34
import java.nio.charset.Charset;
35
import java.nio.file.Files;
36
import java.nio.file.Path;
37
import static java.util.Locale.ENGLISH;
38
import java.util.function.Consumer;
39
import javafx.application.Platform;
40
import javafx.beans.binding.Bindings;
41
import javafx.beans.property.BooleanProperty;
42
import javafx.beans.property.ReadOnlyBooleanProperty;
43
import javafx.beans.property.ReadOnlyBooleanWrapper;
44
import javafx.beans.property.SimpleBooleanProperty;
45
import javafx.event.Event;
46
import javafx.scene.control.SplitPane;
47
import javafx.scene.control.Tab;
48
import javafx.scene.control.Tooltip;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.text.Text;
51
import org.fxmisc.flowless.VirtualizedScrollPane;
52
import org.fxmisc.richtext.StyleClassedTextArea;
53
import org.fxmisc.undo.UndoManager;
54
import org.fxmisc.wellbehaved.event.EventPattern;
55
import org.fxmisc.wellbehaved.event.InputMap;
56
import org.mozilla.universalchardet.UniversalDetector;
57
58
/**
59
 * Editor for a single file.
60
 *
61
 * @author Karl Tauber and White Magic Software, Ltd.
62
 */
63
public final class FileEditorTab extends Tab {
64
65
  private final Options options = Services.load( Options.class );
66
  private final AlertService alertService = Services.load( AlertService.class );
67
68
  private EditorPane editorPane;
69
  private HTMLPreviewPane previewPane;
70
71
  /**
72
   * Character encoding used by the file (or default encoding if none found).
73
   */
74
  private Charset encoding;
75
76
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
77
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
78
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
79
  private Path path;
80
81
  FileEditorTab( final Path path ) {
82
    setPath( path );
83
    setUserData( this );
84
85
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
86
    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
122
    return (filePath == null)
123
      ? null
124
      : new Tooltip( filePath.toString() );
125
  }
126
127
  /**
128
   * Returns a marker to indicate whether the file has been modified.
129
   *
130
   * @return "*" when the file has changed; otherwise null.
131
   */
132
  private Text getModifiedMark() {
133
    return isModified() ? new Text( "*" ) : null;
134
  }
135
136
  /**
137
   * Called when the user switches tab.
138
   */
139
  private void activated() {
140
    // Tab is closed or no longer active.
141
    if( getTabPane() == null || !isSelected() ) {
142
      return;
143
    }
144
145
    // Switch to the tab without loading if the contents are already in memory.
146
    if( getContent() != null ) {
147
      getEditorPane().requestFocus();
148
      return;
149
    }
150
151
    // Load the text and update the preview before the undo manager.
152
    load();
153
154
    // Track undo requests (*must* be called after load).
155
    initUndoManager();
156
    initSplitPane();
157
    initFocus();
158
  }
159
160
  public void initSplitPane() {
161
    final EditorPane editor = getEditorPane();
162
    final HTMLPreviewPane preview = getPreviewPane();
163
    final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane = editor.getScrollPane();
164
165
    // Make the preview pane scroll correspond to the editor pane scroll.
166
    // Separate the edit and preview panels.
167
    setContent( new SplitPane( editorScrollPane, preview.getNode() ) );
168
  }
169
170
  private void initFocus() {
171
    getEditorPane().requestFocus();
172
  }
173
174
  private void initUndoManager() {
175
    final UndoManager undoManager = getUndoManager();
176
177
    // Clear undo history after first load.
178
    undoManager.forgetHistory();
179
180
    // Bind the editor undo manager to the properties.
181
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
182
    canUndo.bind( undoManager.undoAvailableProperty() );
183
    canRedo.bind( undoManager.redoAvailableProperty() );
184
  }
185
186
  /**
187
   * Returns the index into the text where the caret blinks happily away.
188
   *
189
   * @return A number from 0 to the editor's document text length.
190
   */
191
  public int getCaretPosition() {
192
    return getEditorPane().getEditor().getCaretPosition();
193
  }
194
195
  /**
196
   * Returns true if the given path exactly matches this tab's path.
197
   *
198
   * @param check The path to compare against.
199
   *
200
   * @return true The paths are the same.
201
   */
202
  public boolean isPath( final Path check ) {
203
    final Path filePath = getPath();
204
205
    return filePath == null ? false : filePath.equals( check );
206
  }
207
208
  /**
209
   * Reads the entire file contents from the path associated with this tab.
210
   */
211
  private void load() {
212
    final Path filePath = getPath();
213
214
    if( filePath != null ) {
215
      try {
216
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
217
      } catch( Exception ex ) {
218
        alert(
219
          "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
220
        );
221
      }
222
    }
223
  }
224
225
  /**
226
   * Saves the entire file contents from the path associated with this tab.
227
   *
228
   * @return true The file has been saved.
229
   */
230
  public boolean save() {
231
    try {
232
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
233
      getEditorPane().getUndoManager().mark();
234
      return true;
235
    } catch( Exception ex ) {
236
      return alert(
237
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
238
      );
239
    }
240
  }
241
242
  /**
243
   * Creates an alert dialog and waits for it to close.
244
   *
245
   * @param titleKey Resource bundle key for the alert dialog title.
246
   * @param messageKey Resource bundle key for the alert dialog message.
247
   * @param e The unexpected happening.
248
   *
249
   * @return false
250
   */
251
  private boolean alert( String titleKey, String messageKey, Exception e ) {
252
    final AlertService service = getAlertService();
253
254
    final AlertMessage message = service.createAlertMessage(
255
      Messages.get( titleKey ),
256
      Messages.get( messageKey ),
257
      getPath(),
258
      e.getMessage()
259
    );
260
261
    service.createAlertError( message ).showAndWait();
262
    return false;
263
  }
264
265
  /**
266
   * Returns a best guess at the file encoding. If the encoding could not be
267
   * detected, this will return the default charset for the JVM.
268
   *
269
   * @param bytes The bytes to perform character encoding detection.
270
   *
271
   * @return The character encoding.
272
   */
273
  private Charset detectEncoding( final byte[] bytes ) {
274
    final UniversalDetector detector = new UniversalDetector( null );
275
    detector.handleData( bytes, 0, bytes.length );
276
    detector.dataEnd();
277
278
    final String charset = detector.getDetectedCharset();
279
    final Charset charEncoding = charset == null
280
      ? Charset.defaultCharset()
281
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
282
283
    detector.reset();
284
285
    return charEncoding;
286
  }
287
288
  /**
289
   * Converts the given string to an array of bytes using the encoding that was
290
   * originally detected (if any) and associated with this file.
291
   *
292
   * @param text The text to convert into the original file encoding.
293
   *
294
   * @return A series of bytes ready for writing to a file.
295
   */
296
  private byte[] asBytes( final String text ) {
297
    return text.getBytes( getEncoding() );
298
  }
299
300
  /**
301
   * Converts the given bytes into a Java String. This will call setEncoding
302
   * with the encoding detected by the CharsetDetector.
303
   *
304
   * @param text The text of unknown character encoding.
305
   *
306
   * @return The text, in its auto-detected encoding, as a String.
307
   */
308
  private String asString( final byte[] text ) {
309
    setEncoding( detectEncoding( text ) );
310
    return new String( text, getEncoding() );
311
  }
312
313
  Path getPath() {
314
    return this.path;
315
  }
316
317
  void setPath( final Path path ) {
318
    this.path = path;
319
  }
320
321
  public boolean isModified() {
322
    return this.modified.get();
323
  }
324
325
  ReadOnlyBooleanProperty modifiedProperty() {
326
    return this.modified.getReadOnlyProperty();
327
  }
328
329
  BooleanProperty canUndoProperty() {
330
    return this.canUndo;
331
  }
332
333
  BooleanProperty canRedoProperty() {
334
    return this.canRedo;
335
  }
336
337
  private UndoManager getUndoManager() {
338
    return getEditorPane().getUndoManager();
339
  }
340
341
  /**
342
   * Forwards the request to the editor pane.
343
   *
344
   * @param <T> The type of event listener to add.
345
   * @param <U> The type of consumer to add.
346
   * @param event The event that should trigger updates to the listener.
347
   * @param consumer The listener to receive update events.
348
   */
349
  public <T extends Event, U extends T> void addEventListener(
350
    final EventPattern<? super T, ? extends U> event,
351
    final Consumer<? super U> consumer ) {
352
    getEditorPane().addEventListener( event, consumer );
353
  }
354
355
  /**
356
   * Forwards to the editor pane's listeners for keyboard events.
357
   *
358
   * @param map The new input map to replace the existing keyboard listener.
359
   */
360
  public void addEventListener( final InputMap<InputEvent> map ) {
361
    getEditorPane().addEventListener( map );
362
  }
363
364
  /**
365
   * Forwards to the editor pane's listeners for keyboard events.
366
   *
367
   * @param map The existing input map to remove from the keyboard listeners.
368
   */
369
  public void removeEventListener( final InputMap<InputEvent> map ) {
370
    getEditorPane().removeEventListener( map );
371
  }
372
373
  /**
374
   * Returns the editor pane, or creates one if it doesn't yet exist.
375
   *
376
   * @return The editor pane, never null.
377
   */
378
  protected EditorPane getEditorPane() {
379
    if( this.editorPane == null ) {
380
      this.editorPane = new MarkdownEditorPane();
381
    }
382
383
    return this.editorPane;
384
  }
385
386
  private AlertService getAlertService() {
387
    return this.alertService;
388
  }
389
390
  private Options getOptions() {
391
    return this.options;
392
  }
393
394
  public HTMLPreviewPane getPreviewPane() {
395
    if( this.previewPane == null ) {
396
      this.previewPane = new HTMLPreviewPane( getPath() );
397
    }
398
399
    return this.previewPane;
30
import com.scrivenvar.service.Options;
31
import com.scrivenvar.service.events.AlertMessage;
32
import com.scrivenvar.service.events.AlertService;
33
import java.nio.charset.Charset;
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.event.Event;
46
import javafx.scene.Node;
47
import javafx.scene.control.Tab;
48
import javafx.scene.control.Tooltip;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.text.Text;
51
import org.fxmisc.undo.UndoManager;
52
import org.fxmisc.wellbehaved.event.EventPattern;
53
import org.fxmisc.wellbehaved.event.InputMap;
54
import org.mozilla.universalchardet.UniversalDetector;
55
56
/**
57
 * Editor for a single file.
58
 *
59
 * @author Karl Tauber and White Magic Software, Ltd.
60
 */
61
public final class FileEditorTab extends Tab {
62
63
  private final Options options = Services.load( Options.class );
64
  private final AlertService alertService = Services.load( AlertService.class );
65
66
  private EditorPane editorPane;
67
68
  /**
69
   * Character encoding used by the file (or default encoding if none found).
70
   */
71
  private Charset encoding;
72
73
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
74
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
75
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
76
  private Path path;
77
78
  FileEditorTab( final Path path ) {
79
    setPath( path );
80
    setUserData( this );
81
82
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
83
    updateTab();
84
85
    setOnSelectionChanged( e -> {
86
      if( isSelected() ) {
87
        Platform.runLater( () -> activated() );
88
      }
89
    } );
90
  }
91
92
  private void updateTab() {
93
    setText( getTabTitle() );
94
    setGraphic( getModifiedMark() );
95
    setTooltip( getTabTooltip() );
96
  }
97
98
  /**
99
   * Returns the base filename (without the directory names).
100
   *
101
   * @return The untitled text if the path hasn't been set.
102
   */
103
  private String getTabTitle() {
104
    final Path filePath = getPath();
105
106
    return (filePath == null)
107
      ? Messages.get( "FileEditor.untitled" )
108
      : filePath.getFileName().toString();
109
  }
110
111
  /**
112
   * Returns the full filename represented by the path.
113
   *
114
   * @return The untitled text if the path hasn't been set.
115
   */
116
  private Tooltip getTabTooltip() {
117
    final Path filePath = getPath();
118
119
    return (filePath == null)
120
      ? null
121
      : new Tooltip( 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 (*must* 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
   * Returns the index into the text where the caret blinks happily away.
183
   *
184
   * @return A number from 0 to the editor's document text length.
185
   */
186
  public int getCaretPosition() {
187
    return getEditorPane().getEditor().getCaretPosition();
188
  }
189
190
  /**
191
   * Returns true if the given path exactly matches this tab's path.
192
   *
193
   * @param check The path to compare against.
194
   *
195
   * @return true The paths are the same.
196
   */
197
  public boolean isPath( final Path check ) {
198
    final Path filePath = getPath();
199
200
    return filePath == null ? false : filePath.equals( check );
201
  }
202
203
  /**
204
   * Reads the entire file contents from the path associated with this tab.
205
   */
206
  private void load() {
207
    final Path filePath = getPath();
208
209
    if( filePath != null ) {
210
      try {
211
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
212
      } catch( Exception ex ) {
213
        alert(
214
          "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
215
        );
216
      }
217
    }
218
  }
219
220
  /**
221
   * Saves the entire file contents from the path associated with this tab.
222
   *
223
   * @return true The file has been saved.
224
   */
225
  public boolean save() {
226
    try {
227
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
228
      getEditorPane().getUndoManager().mark();
229
      return true;
230
    } catch( Exception ex ) {
231
      return alert(
232
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
233
      );
234
    }
235
  }
236
237
  /**
238
   * Creates an alert dialog and waits for it to close.
239
   *
240
   * @param titleKey Resource bundle key for the alert dialog title.
241
   * @param messageKey Resource bundle key for the alert dialog message.
242
   * @param e The unexpected happening.
243
   *
244
   * @return false
245
   */
246
  private boolean alert(
247
    final String titleKey, final String messageKey, final Exception e ) {
248
    final AlertService service = getAlertService();
249
250
    final AlertMessage message = service.createAlertMessage(
251
      Messages.get( titleKey ),
252
      Messages.get( messageKey ),
253
      getPath(),
254
      e.getMessage()
255
    );
256
257
    service.createAlertError( message ).showAndWait();
258
    return false;
259
  }
260
261
  /**
262
   * Returns a best guess at the file encoding. If the encoding could not be
263
   * detected, this will return the default charset for the JVM.
264
   *
265
   * @param bytes The bytes to perform character encoding detection.
266
   *
267
   * @return The character encoding.
268
   */
269
  private Charset detectEncoding( final byte[] bytes ) {
270
    final UniversalDetector detector = new UniversalDetector( null );
271
    detector.handleData( bytes, 0, bytes.length );
272
    detector.dataEnd();
273
274
    final String charset = detector.getDetectedCharset();
275
    final Charset charEncoding = charset == null
276
      ? Charset.defaultCharset()
277
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
278
279
    detector.reset();
280
281
    return charEncoding;
282
  }
283
284
  /**
285
   * Converts the given string to an array of bytes using the encoding that was
286
   * originally detected (if any) and associated with this file.
287
   *
288
   * @param text The text to convert into the original file encoding.
289
   *
290
   * @return A series of bytes ready for writing to a file.
291
   */
292
  private byte[] asBytes( final String text ) {
293
    return text.getBytes( getEncoding() );
294
  }
295
296
  /**
297
   * Converts the given bytes into a Java String. This will call setEncoding
298
   * with the encoding detected by the CharsetDetector.
299
   *
300
   * @param text The text of unknown character encoding.
301
   *
302
   * @return The text, in its auto-detected encoding, as a String.
303
   */
304
  private String asString( final byte[] text ) {
305
    setEncoding( detectEncoding( text ) );
306
    return new String( text, getEncoding() );
307
  }
308
309
  Path getPath() {
310
    return this.path;
311
  }
312
313
  void setPath( final Path path ) {
314
    this.path = path;
315
  }
316
317
  public boolean isModified() {
318
    return this.modified.get();
319
  }
320
321
  ReadOnlyBooleanProperty modifiedProperty() {
322
    return this.modified.getReadOnlyProperty();
323
  }
324
325
  BooleanProperty canUndoProperty() {
326
    return this.canUndo;
327
  }
328
329
  BooleanProperty canRedoProperty() {
330
    return this.canRedo;
331
  }
332
333
  private UndoManager getUndoManager() {
334
    return getEditorPane().getUndoManager();
335
  }
336
337
  /**
338
   * Forwards the request to the editor pane.
339
   *
340
   * @param <T> The type of event listener to add.
341
   * @param <U> The type of consumer to add.
342
   * @param event The event that should trigger updates to the listener.
343
   * @param consumer The listener to receive update events.
344
   */
345
  public <T extends Event, U extends T> void addEventListener(
346
    final EventPattern<? super T, ? extends U> event,
347
    final Consumer<? super U> consumer ) {
348
    getEditorPane().addEventListener( event, consumer );
349
  }
350
351
  /**
352
   * Forwards to the editor pane's listeners for keyboard events.
353
   *
354
   * @param map The new input map to replace the existing keyboard listener.
355
   */
356
  public void addEventListener( final InputMap<InputEvent> map ) {
357
    getEditorPane().addEventListener( map );
358
  }
359
360
  /**
361
   * Forwards to the editor pane's listeners for keyboard events.
362
   *
363
   * @param map The existing input map to remove from the keyboard listeners.
364
   */
365
  public void removeEventListener( final InputMap<InputEvent> map ) {
366
    getEditorPane().removeEventListener( map );
367
  }
368
369
  /**
370
   * Forwards to the editor pane's listeners for text change events.
371
   *
372
   * @param listener The listener to notify when the text changes.
373
   */
374
  public void addTextChangeListener( final ChangeListener<String> listener ) {
375
    getEditorPane().addTextChangeListener( listener );
376
  }
377
  
378
  /**
379
   * Forwards to the editor pane's listeners for paragraph change events.
380
   *
381
   * @param listener The listener to notify when the caret changes paragraphs.
382
   */
383
  public void addCaretParagraphListener( final ChangeListener<Integer> listener){
384
    getEditorPane().addCaretParagraphListener( listener );
385
  }
386
  
387
  /**
388
   * Delegates the request to the editor pane.
389
   *
390
   * @return The text to process.
391
   */
392
  public String getEditorText() {
393
    return getEditorPane().getText();
394
  }
395
396
  /**
397
   * Returns the editor pane, or creates one if it doesn't yet exist.
398
   *
399
   * @return The editor pane, never null.
400
   */
401
  protected EditorPane getEditorPane() {
402
    if( this.editorPane == null ) {
403
      this.editorPane = new MarkdownEditorPane();
404
    }
405
406
    return this.editorPane;
407
  }
408
409
  private AlertService getAlertService() {
410
    return this.alertService;
411
  }
412
413
  private Options getOptions() {
414
    return this.options;
400415
  }
401416
M src/main/java/com/scrivenvar/FileEditorTabPane.java
7272
 * @author Karl Tauber and White Magic Software, Ltd.
7373
 */
74
public class FileEditorTabPane extends TabPane implements ChangeListener<Tab> {
75
76
  private final static String FILTER_PREFIX = "Dialog.file.choose.filter";
77
78
  private final Options options = Services.load( Options.class );
79
  private final Settings settings = Services.load( Settings.class );
80
  private final AlertService alertService = Services.load( AlertService.class );
81
82
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
83
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
84
85
  public FileEditorTabPane() {
86
    final ObservableList<Tab> tabs = getTabs();
87
88
    setFocusTraversable( false );
89
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
90
91
    // Observe the tab so that when a new tab is opened or selected,
92
    // a notification is kicked off.
93
    getSelectionModel().selectedItemProperty().addListener( this );
94
95
    // update anyFileEditorModified property
96
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
97
      for( final Tab tab : tabs ) {
98
        if( ((FileEditorTab)tab.getUserData()).isModified() ) {
99
          this.anyFileEditorModified.set( true );
100
          break;
101
        }
102
      }
103
    };
104
105
    tabs.addListener( (ListChangeListener<Tab>)change -> {
106
      while( change.next() ) {
107
        if( change.wasAdded() ) {
108
          change.getAddedSubList().stream().forEach( (tab) -> {
109
            ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener );
110
          } );
111
        } else if( change.wasRemoved() ) {
112
          change.getRemoved().stream().forEach( (tab) -> {
113
            ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener );
114
          } );
115
        }
116
      }
117
118
      // Changes in the tabs may also change anyFileEditorModified property
119
      // (e.g. closed modified file)
120
      modifiedListener.changed( null, null, null );
121
    } );
122
  }
123
124
  public <T extends Event, U extends T> void addEventListener(
125
    final EventPattern<? super T, ? extends U> event,
126
    final Consumer<? super U> consumer ) {
127
    getActiveFileEditor().addEventListener( event, consumer );
128
  }
129
130
  /**
131
   * Delegates to the active file editor pane, and, ultimately, to its text
132
   * area.
133
   *
134
   * @param map The map of methods to events.
135
   */
136
  public void addEventListener( final InputMap<InputEvent> map ) {
137
    getActiveFileEditor().addEventListener( map );
138
  }
139
140
  public void removeEventListener( final InputMap<InputEvent> map ) {
141
    getActiveFileEditor().removeEventListener( map );
142
  }
143
144
  @Override
145
  public void changed(
146
    final ObservableValue<? extends Tab> observable,
147
    final Tab oldTab,
148
    final Tab newTab ) {
149
150
    if( newTab != null ) {
151
      this.activeFileEditor.set( (FileEditorTab)newTab.getUserData() );
152
    }
153
  }
154
155
  Node getNode() {
156
    return this;
157
  }
158
159
  /**
160
   * Allows clients to manipulate the editor content directly.
161
   *
162
   * @return The text area for the active file editor.
163
   */
164
  public StyledTextArea getEditor() {
165
    return getActiveFileEditor().getEditorPane().getEditor();
166
  }
167
168
  public FileEditorTab getActiveFileEditor() {
169
    return this.activeFileEditor.get();
170
  }
171
172
  ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
173
    return this.activeFileEditor.getReadOnlyProperty();
174
  }
175
176
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
177
    return this.anyFileEditorModified.getReadOnlyProperty();
178
  }
179
180
  private FileEditorTab createFileEditor( final Path path ) {
181
    final FileEditorTab tab = new FileEditorTab( path );
182
183
    tab.setOnCloseRequest( e -> {
184
      if( !canCloseEditor( tab ) ) {
185
        e.consume();
186
      }
187
    } );
188
189
    return tab;
190
  }
191
192
  /**
193
   * Called when the user selects New from the File menu.
194
   *
195
   * @return The newly added tab.
196
   */
197
  FileEditorTab newEditor() {
198
    final FileEditorTab tab = createFileEditor( null );
199
200
    getTabs().add( tab );
201
    getSelectionModel().select( tab );
202
    return tab;
203
  }
204
205
  List<FileEditorTab> openFileDialog() {
206
    final FileChooser dialog
207
      = createFileChooser( get( "Dialog.file.choose.open.title" ) );
208
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
209
210
    return (files != null && !files.isEmpty())
211
      ? openFiles( files )
212
      : new ArrayList<>();
213
  }
214
215
  /**
216
   * Opens the files into new editors, unless one of those files was a
217
   * definition file. The definition file is loaded into the definition pane,
218
   * but only the first one selected (multiple definition files will result in a
219
   * warning).
220
   *
221
   * @param files The list of non-definition files that the were requested to
222
   * open.
223
   *
224
   * @return A list of files that can be opened in text editors.
225
   */
226
  private List<FileEditorTab> openFiles( final List<File> files ) {
227
    final List<FileEditorTab> openedEditors = new ArrayList<>();
228
229
    final FileTypePredicate predicate
230
      = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() );
231
232
    // The user might have opened muliple definitions files. These will
233
    // be discarded from the text editable files.
234
    final List<File> definitions
235
      = files.stream().filter( predicate ).collect( Collectors.toList() );
236
237
    // Create a modifiable list to remove any definition files that were
238
    // opened.
239
    final List<File> editors = new ArrayList<>( files );
240
    editors.removeAll( definitions );
241
242
    // If there are any editor-friendly files opened (e.g,. Markdown, XML), then
243
    // open them up in new tabs.
244
    if( editors.size() > 0 ) {
245
      saveLastDirectory( editors.get( 0 ) );
246
      openedEditors.addAll( openEditors( editors, 0 ) );
247
    }
248
249
    if( definitions.size() > 0 ) {
250
      openDefinition( definitions.get( 0 ) );
251
    }
252
253
    return openedEditors;
254
  }
255
256
  private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) {
257
    final int fileTally = files.size();
258
    final List<FileEditorTab> editors = new ArrayList<>( fileTally );
259
    final List<Tab> tabs = getTabs();
260
261
    // Close single unmodified "Untitled" tab.
262
    if( tabs.size() == 1 ) {
263
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData());
264
265
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
266
        closeEditor( fileEditor, false );
267
      }
268
    }
269
270
    for( int i = 0; i < fileTally; i++ ) {
271
      final Path path = files.get( i ).toPath();
272
273
      // Check whether file is already opened.
274
      FileEditorTab fileEditor = findEditor( path );
275
276
      if( fileEditor == null ) {
277
        fileEditor = createFileEditor( path );
278
        getTabs().add( fileEditor );
279
        editors.add( fileEditor );
280
      }
281
282
      // Select first file.
283
      if( i == activeIndex ) {
284
        getSelectionModel().select( fileEditor );
285
      }
286
    }
287
288
    return editors;
289
  }
290
291
  /**
292
   * Called when the user has opened a definition file (using the file open
293
   * dialog box). This will replace the current set of definitions for the
294
   * active tab.
295
   *
296
   * @param definition The file to open.
297
   */
298
  private void openDefinition( final File definition ) {
299
    System.out.println( "open definition file: " + definition.toString() );
300
  }
301
302
  boolean saveEditor( final FileEditorTab fileEditor ) {
303
    if( fileEditor == null || !fileEditor.isModified() ) {
304
      return true;
305
    }
306
307
    if( fileEditor.getPath() == null ) {
308
      getSelectionModel().select( fileEditor );
309
310
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
311
      final File file = fileChooser.showSaveDialog( getWindow() );
312
      if( file == null ) {
313
        return false;
314
      }
315
316
      saveLastDirectory( file );
317
      fileEditor.setPath( file.toPath() );
318
    }
319
320
    return fileEditor.save();
321
  }
322
323
  boolean saveAllEditors() {
324
    boolean success = true;
325
326
    for( FileEditorTab fileEditor : getAllEditors() ) {
327
      if( !saveEditor( fileEditor ) ) {
328
        success = false;
329
      }
330
    }
331
332
    return success;
333
  }
334
335
  boolean canCloseEditor( final FileEditorTab tab ) {
336
    if( !tab.isModified() ) {
337
      return true;
338
    }
339
340
    final AlertMessage message = getAlertService().createAlertMessage(
341
      Messages.get( "Alert.file.close.title" ),
342
      Messages.get( "Alert.file.close.text" ),
343
      tab.getText()
344
    );
345
346
    final Alert alert = getAlertService().createAlertConfirmation( message );
347
    final ButtonType response = alert.showAndWait().get();
348
349
    return response == YES ? saveEditor( tab ) : response == NO;
350
  }
351
352
  private AlertService getAlertService() {
353
    return this.alertService;
354
  }
355
356
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
357
    if( fileEditor == null ) {
358
      return true;
359
    }
360
361
    final Tab tab = fileEditor;
362
363
    if( save ) {
364
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
365
      Event.fireEvent( tab, event );
366
367
      if( event.isConsumed() ) {
368
        return false;
369
      }
370
    }
371
372
    getTabs().remove( tab );
373
374
    if( tab.getOnClosed() != null ) {
375
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
376
    }
377
378
    return true;
379
  }
380
381
  boolean closeAllEditors() {
382
    final FileEditorTab[] allEditors = getAllEditors();
383
    final FileEditorTab activeEditor = getActiveFileEditor();
384
385
    // try to save active tab first because in case the user decides to cancel,
386
    // then it stays active
387
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
388
      return false;
389
    }
390
391
    // This should be called any time a tab changes.
392
    persistPreferences();
393
394
    // save modified tabs
395
    for( int i = 0; i < allEditors.length; i++ ) {
396
      final FileEditorTab fileEditor = allEditors[ i ];
397
398
      if( fileEditor == activeEditor ) {
399
        continue;
400
      }
401
402
      if( fileEditor.isModified() ) {
403
        // activate the modified tab to make its modified content visible to the user
404
        getSelectionModel().select( i );
405
406
        if( !canCloseEditor( fileEditor ) ) {
407
          return false;
408
        }
409
      }
410
    }
411
412
    // Close all tabs.
413
    for( final FileEditorTab fileEditor : allEditors ) {
414
      if( !closeEditor( fileEditor, false ) ) {
415
        return false;
416
      }
417
    }
418
419
    return getTabs().isEmpty();
420
  }
421
422
  private FileEditorTab[] getAllEditors() {
423
    final ObservableList<Tab> tabs = getTabs();
424
    final FileEditorTab[] allEditors = new FileEditorTab[ tabs.size() ];
425
    final int length = tabs.size();
426
427
    for( int i = 0; i < length; i++ ) {
428
      allEditors[ i ] = (FileEditorTab)tabs.get( i ).getUserData();
429
    }
430
431
    return allEditors;
432
  }
433
434
  /**
435
   * Returns the file editor tab that has the given path.
436
   *
437
   * @return null No file editor tab for the given path was found.
438
   */
439
  private FileEditorTab findEditor( final Path path ) {
440
    for( final Tab tab : getTabs() ) {
441
      final FileEditorTab fileEditor = (FileEditorTab)tab;
442
443
      System.out.println( "path = " + path );
444
      System.out.println( "fileEditor = " + fileEditor.isPath( path ) );
445
446
      if( fileEditor.isPath( path ) ) {
447
        return fileEditor;
448
      }
449
    }
450
451
    return null;
452
  }
453
454
  private FileChooser createFileChooser( String title ) {
455
    final FileChooser fileChooser = new FileChooser();
456
457
    fileChooser.setTitle( title );
458
    fileChooser.getExtensionFilters().addAll(
459
      createExtensionFilters() );
460
461
    final String lastDirectory = getState().get( "lastDirectory", null );
462
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
463
464
    if( !file.isDirectory() ) {
465
      file = new File( "." );
466
    }
467
468
    fileChooser.setInitialDirectory( file );
469
    return fileChooser;
470
  }
471
472
  private List<ExtensionFilter> createExtensionFilters() {
473
    final List<ExtensionFilter> list = new ArrayList<>();
474
475
    // TODO: Return a list of all properties that match the filter prefix.
476
    // This will allow dynamic filters to be added and removed just by
477
    // updating the properties file.
478
    list.add( createExtensionFilter( "markdown" ) );
479
    list.add( createExtensionFilter( "definition" ) );
480
    list.add( createExtensionFilter( "xml" ) );
481
    list.add( createExtensionFilter( "all" ) );
482
    return list;
483
  }
484
485
  private ExtensionFilter createExtensionFilter( final String filetype ) {
486
    final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype );
487
    final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype );
488
489
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
490
  }
491
492
  private List<String> getExtensions( final String key ) {
493
    return getStringSettingList( key );
494
  }
495
496
  private List<String> getStringSettingList( String key ) {
497
    return getStringSettingList( key, null );
498
  }
499
500
  private List<String> getStringSettingList( String key, List<String> values ) {
501
    return getSettings().getStringSettingList( key, values );
502
  }
503
504
  private void saveLastDirectory( final File file ) {
505
    getState().put( "lastDirectory", file.getParent() );
506
  }
507
508
  public void restorePreferences() {
509
    int activeIndex = 0;
510
511
    final Preferences preferences = getState();
512
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
513
    final String activeFileName = preferences.get( "activeFile", null );
514
515
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
516
517
    for( final String fileName : fileNames ) {
518
      final File file = new File( fileName );
519
520
      if( file.exists() ) {
521
        files.add( file );
522
523
        if( fileName.equals( activeFileName ) ) {
524
          activeIndex = files.size() - 1;
525
        }
526
      }
527
    }
528
529
    if( files.isEmpty() ) {
530
      newEditor();
531
      return;
532
    }
533
534
    openEditors( files, activeIndex );
535
  }
536
537
  public void persistPreferences() {
538
    final ObservableList<Tab> allEditors = getTabs();
539
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
540
541
    for( final Tab tab : allEditors ) {
542
      final FileEditorTab fileEditor = (FileEditorTab)tab;
543
544
      if( fileEditor.getPath() != null ) {
545
        fileNames.add( fileEditor.getPath().toString() );
546
      }
547
    }
548
549
    final Preferences preferences = getState();
550
    Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
551
552
    final FileEditorTab activeEditor = getActiveFileEditor();
553
554
    if( activeEditor != null && activeEditor.getPath() != null ) {
555
      preferences.put( "activeFile", activeEditor.getPath().toString() );
556
    } else {
557
      preferences.remove( "activeFile" );
558
    }
559
  }
560
561
  private Settings getSettings() {
562
    return this.settings;
563
  }
564
565
  protected Options getOptions() {
566
    return this.options;
567
  }
568
569
  private Window getWindow() {
570
    return getScene().getWindow();
571
  }
572
74
public final class FileEditorTabPane extends TabPane {
75
  
76
  private final static String FILTER_PREFIX = "Dialog.file.choose.filter";
77
  
78
  private final Options options = Services.load( Options.class );
79
  private final Settings settings = Services.load( Settings.class );
80
  private final AlertService alertService = Services.load( AlertService.class );
81
  
82
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
83
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
84
  
85
  public FileEditorTabPane() {
86
    final ObservableList<Tab> tabs = getTabs();
87
    
88
    setFocusTraversable( false );
89
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
90
    
91
    addTabChangeListener( (ObservableValue<? extends Tab> tabPane,
92
      final Tab oldTab, final Tab newTab) -> {
93
      if( newTab != null ) {
94
        activeFileEditor.set( (FileEditorTab)newTab.getUserData() );
95
      }
96
    } );
97
    
98
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
99
      for( final Tab tab : tabs ) {
100
        if( ((FileEditorTab)tab.getUserData()).isModified() ) {
101
          this.anyFileEditorModified.set( true );
102
          break;
103
        }
104
      }
105
    };
106
    
107
    tabs.addListener( (ListChangeListener<Tab>)change -> {
108
      while( change.next() ) {
109
        if( change.wasAdded() ) {
110
          change.getAddedSubList().stream().forEach( (tab) -> {
111
            ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener );
112
          } );
113
        } else if( change.wasRemoved() ) {
114
          change.getRemoved().stream().forEach( (tab) -> {
115
            ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener );
116
          } );
117
        }
118
      }
119
120
      // Changes in the tabs may also change anyFileEditorModified property
121
      // (e.g. closed modified file)
122
      modifiedListener.changed( null, null, null );
123
    } );
124
  }
125
  
126
  public <T extends Event, U extends T> void addEventListener(
127
    final EventPattern<? super T, ? extends U> event,
128
    final Consumer<? super U> consumer ) {
129
    getActiveFileEditor().addEventListener( event, consumer );
130
  }
131
132
  /**
133
   * Delegates to the active file editor pane, and, ultimately, to its text
134
   * area.
135
   *
136
   * @param map The map of methods to events.
137
   */
138
  public void addEventListener( final InputMap<InputEvent> map ) {
139
    getActiveFileEditor().addEventListener( map );
140
  }
141
142
  /**
143
   * Remove a keyboard event listener from the active file editor.
144
   *
145
   * @param map The keyboard events to remove.
146
   */
147
  public void removeEventListener( final InputMap<InputEvent> map ) {
148
    getActiveFileEditor().removeEventListener( map );
149
  }
150
151
  /**
152
   * Allows observers to be notified when the current file editor tab changes.
153
   *
154
   * @param listener The listener to notify of tab change events.
155
   */
156
  public void addTabChangeListener( final ChangeListener<Tab> listener ) {
157
    // Observe the tab so that when a new tab is opened or selected,
158
    // a notification is kicked off.
159
    getSelectionModel().selectedItemProperty().addListener( listener );
160
  }
161
  
162
  /**
163
   * Allows clients to manipulate the editor content directly.
164
   *
165
   * @return The text area for the active file editor.
166
   */
167
  public StyledTextArea getEditor() {
168
    return getActiveFileEditor().getEditorPane().getEditor();
169
  }
170
  
171
  public FileEditorTab getActiveFileEditor() {
172
    return this.activeFileEditor.get();
173
  }
174
  
175
  ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
176
    return this.activeFileEditor.getReadOnlyProperty();
177
  }
178
  
179
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
180
    return this.anyFileEditorModified.getReadOnlyProperty();
181
  }
182
  
183
  private FileEditorTab createFileEditor( final Path path ) {
184
    final FileEditorTab tab = new FileEditorTab( path );
185
    
186
    tab.setOnCloseRequest( e -> {
187
      if( !canCloseEditor( tab ) ) {
188
        e.consume();
189
      }
190
    } );
191
    
192
    return tab;
193
  }
194
195
  Node getNode() {
196
    return this;
197
  }
198
199
  /**
200
   * Called when the user selects New from the File menu.
201
   *
202
   * @return The newly added tab.
203
   */
204
  FileEditorTab newEditor() {
205
    final FileEditorTab tab = createFileEditor( null );
206
    
207
    getTabs().add( tab );
208
    getSelectionModel().select( tab );
209
    return tab;
210
  }
211
  
212
  List<FileEditorTab> openFileDialog() {
213
    final FileChooser dialog
214
      = createFileChooser( get( "Dialog.file.choose.open.title" ) );
215
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
216
    
217
    return (files != null && !files.isEmpty())
218
      ? openFiles( files )
219
      : new ArrayList<>();
220
  }
221
222
  /**
223
   * Opens the files into new editors, unless one of those files was a
224
   * definition file. The definition file is loaded into the definition pane,
225
   * but only the first one selected (multiple definition files will result in a
226
   * warning).
227
   *
228
   * @param files The list of non-definition files that the were requested to
229
   * open.
230
   *
231
   * @return A list of files that can be opened in text editors.
232
   */
233
  private List<FileEditorTab> openFiles( final List<File> files ) {
234
    final List<FileEditorTab> openedEditors = new ArrayList<>();
235
    
236
    final FileTypePredicate predicate
237
      = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() );
238
239
    // The user might have opened muliple definitions files. These will
240
    // be discarded from the text editable files.
241
    final List<File> definitions
242
      = files.stream().filter( predicate ).collect( Collectors.toList() );
243
244
    // Create a modifiable list to remove any definition files that were
245
    // opened.
246
    final List<File> editors = new ArrayList<>( files );
247
    editors.removeAll( definitions );
248
249
    // If there are any editor-friendly files opened (e.g,. Markdown, XML), then
250
    // open them up in new tabs.
251
    if( editors.size() > 0 ) {
252
      saveLastDirectory( editors.get( 0 ) );
253
      openedEditors.addAll( openEditors( editors, 0 ) );
254
    }
255
    
256
    if( definitions.size() > 0 ) {
257
      openDefinition( definitions.get( 0 ) );
258
    }
259
    
260
    return openedEditors;
261
  }
262
  
263
  private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) {
264
    final int fileTally = files.size();
265
    final List<FileEditorTab> editors = new ArrayList<>( fileTally );
266
    final List<Tab> tabs = getTabs();
267
268
    // Close single unmodified "Untitled" tab.
269
    if( tabs.size() == 1 ) {
270
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData());
271
      
272
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
273
        closeEditor( fileEditor, false );
274
      }
275
    }
276
    
277
    for( int i = 0; i < fileTally; i++ ) {
278
      final Path path = files.get( i ).toPath();
279
280
      // Check whether file is already opened.
281
      FileEditorTab fileEditor = findEditor( path );
282
      
283
      if( fileEditor == null ) {
284
        fileEditor = createFileEditor( path );
285
        getTabs().add( fileEditor );
286
        editors.add( fileEditor );
287
      }
288
289
      // Select first file.
290
      if( i == activeIndex ) {
291
        getSelectionModel().select( fileEditor );
292
      }
293
    }
294
    
295
    return editors;
296
  }
297
298
  /**
299
   * Called when the user has opened a definition file (using the file open
300
   * dialog box). This will replace the current set of definitions for the
301
   * active tab.
302
   *
303
   * @param definition The file to open.
304
   */
305
  private void openDefinition( final File definition ) {
306
    System.out.println( "open definition file: " + definition.toString() );
307
  }
308
  
309
  boolean saveEditor( final FileEditorTab fileEditor ) {
310
    if( fileEditor == null || !fileEditor.isModified() ) {
311
      return true;
312
    }
313
    
314
    if( fileEditor.getPath() == null ) {
315
      getSelectionModel().select( fileEditor );
316
      
317
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
318
      final File file = fileChooser.showSaveDialog( getWindow() );
319
      if( file == null ) {
320
        return false;
321
      }
322
      
323
      saveLastDirectory( file );
324
      fileEditor.setPath( file.toPath() );
325
    }
326
    
327
    return fileEditor.save();
328
  }
329
  
330
  boolean saveAllEditors() {
331
    boolean success = true;
332
    
333
    for( FileEditorTab fileEditor : getAllEditors() ) {
334
      if( !saveEditor( fileEditor ) ) {
335
        success = false;
336
      }
337
    }
338
    
339
    return success;
340
  }
341
  
342
  boolean canCloseEditor( final FileEditorTab tab ) {
343
    if( !tab.isModified() ) {
344
      return true;
345
    }
346
    
347
    final AlertMessage message = getAlertService().createAlertMessage(
348
      Messages.get( "Alert.file.close.title" ),
349
      Messages.get( "Alert.file.close.text" ),
350
      tab.getText()
351
    );
352
    
353
    final Alert alert = getAlertService().createAlertConfirmation( message );
354
    final ButtonType response = alert.showAndWait().get();
355
    
356
    return response == YES ? saveEditor( tab ) : response == NO;
357
  }
358
  
359
  private AlertService getAlertService() {
360
    return this.alertService;
361
  }
362
  
363
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
364
    if( fileEditor == null ) {
365
      return true;
366
    }
367
    
368
    final Tab tab = fileEditor;
369
    
370
    if( save ) {
371
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
372
      Event.fireEvent( tab, event );
373
      
374
      if( event.isConsumed() ) {
375
        return false;
376
      }
377
    }
378
    
379
    getTabs().remove( tab );
380
    
381
    if( tab.getOnClosed() != null ) {
382
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
383
    }
384
    
385
    return true;
386
  }
387
  
388
  boolean closeAllEditors() {
389
    final FileEditorTab[] allEditors = getAllEditors();
390
    final FileEditorTab activeEditor = getActiveFileEditor();
391
392
    // try to save active tab first because in case the user decides to cancel,
393
    // then it stays active
394
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
395
      return false;
396
    }
397
398
    // This should be called any time a tab changes.
399
    persistPreferences();
400
401
    // save modified tabs
402
    for( int i = 0; i < allEditors.length; i++ ) {
403
      final FileEditorTab fileEditor = allEditors[ i ];
404
      
405
      if( fileEditor == activeEditor ) {
406
        continue;
407
      }
408
      
409
      if( fileEditor.isModified() ) {
410
        // activate the modified tab to make its modified content visible to the user
411
        getSelectionModel().select( i );
412
        
413
        if( !canCloseEditor( fileEditor ) ) {
414
          return false;
415
        }
416
      }
417
    }
418
419
    // Close all tabs.
420
    for( final FileEditorTab fileEditor : allEditors ) {
421
      if( !closeEditor( fileEditor, false ) ) {
422
        return false;
423
      }
424
    }
425
    
426
    return getTabs().isEmpty();
427
  }
428
  
429
  private FileEditorTab[] getAllEditors() {
430
    final ObservableList<Tab> tabs = getTabs();
431
    final int length = tabs.size();
432
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
433
    
434
    for( int i = 0; i < length; i++ ) {
435
      allEditors[ i ] = (FileEditorTab)tabs.get( i ).getUserData();
436
    }
437
    
438
    return allEditors;
439
  }
440
441
  /**
442
   * Returns the file editor tab that has the given path.
443
   *
444
   * @return null No file editor tab for the given path was found.
445
   */
446
  private FileEditorTab findEditor( final Path path ) {
447
    for( final Tab tab : getTabs() ) {
448
      final FileEditorTab fileEditor = (FileEditorTab)tab;
449
      
450
      if( fileEditor.isPath( path ) ) {
451
        return fileEditor;
452
      }
453
    }
454
    
455
    return null;
456
  }
457
  
458
  private FileChooser createFileChooser( String title ) {
459
    final FileChooser fileChooser = new FileChooser();
460
    
461
    fileChooser.setTitle( title );
462
    fileChooser.getExtensionFilters().addAll(
463
      createExtensionFilters() );
464
    
465
    final String lastDirectory = getState().get( "lastDirectory", null );
466
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
467
    
468
    if( !file.isDirectory() ) {
469
      file = new File( "." );
470
    }
471
    
472
    fileChooser.setInitialDirectory( file );
473
    return fileChooser;
474
  }
475
  
476
  private List<ExtensionFilter> createExtensionFilters() {
477
    final List<ExtensionFilter> list = new ArrayList<>();
478
479
    // TODO: Return a list of all properties that match the filter prefix.
480
    // This will allow dynamic filters to be added and removed just by
481
    // updating the properties file.
482
    list.add( createExtensionFilter( "markdown" ) );
483
    list.add( createExtensionFilter( "definition" ) );
484
    list.add( createExtensionFilter( "xml" ) );
485
    list.add( createExtensionFilter( "all" ) );
486
    return list;
487
  }
488
  
489
  private ExtensionFilter createExtensionFilter( final String filetype ) {
490
    final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype );
491
    final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype );
492
    
493
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
494
  }
495
  
496
  private List<String> getExtensions( final String key ) {
497
    return getStringSettingList( key );
498
  }
499
  
500
  private List<String> getStringSettingList( String key ) {
501
    return getStringSettingList( key, null );
502
  }
503
  
504
  private List<String> getStringSettingList( String key, List<String> values ) {
505
    return getSettings().getStringSettingList( key, values );
506
  }
507
  
508
  private void saveLastDirectory( final File file ) {
509
    getState().put( "lastDirectory", file.getParent() );
510
  }
511
  
512
  public void restorePreferences() {
513
    int activeIndex = 0;
514
    
515
    final Preferences preferences = getState();
516
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
517
    final String activeFileName = preferences.get( "activeFile", null );
518
    
519
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
520
    
521
    for( final String fileName : fileNames ) {
522
      final File file = new File( fileName );
523
      
524
      if( file.exists() ) {
525
        files.add( file );
526
        
527
        if( fileName.equals( activeFileName ) ) {
528
          activeIndex = files.size() - 1;
529
        }
530
      }
531
    }
532
    
533
    if( files.isEmpty() ) {
534
      newEditor();
535
      return;
536
    }
537
    
538
    openEditors( files, activeIndex );
539
  }
540
  
541
  public void persistPreferences() {
542
    final ObservableList<Tab> allEditors = getTabs();
543
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
544
    
545
    for( final Tab tab : allEditors ) {
546
      final FileEditorTab fileEditor = (FileEditorTab)tab;
547
      
548
      if( fileEditor.getPath() != null ) {
549
        fileNames.add( fileEditor.getPath().toString() );
550
      }
551
    }
552
    
553
    final Preferences preferences = getState();
554
    Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
555
    
556
    final FileEditorTab activeEditor = getActiveFileEditor();
557
    
558
    if( activeEditor != null && activeEditor.getPath() != null ) {
559
      preferences.put( "activeFile", activeEditor.getPath().toString() );
560
    } else {
561
      preferences.remove( "activeFile" );
562
    }
563
  }
564
  
565
  private Settings getSettings() {
566
    return this.settings;
567
  }
568
  
569
  protected Options getOptions() {
570
    return this.options;
571
  }
572
  
573
  private Window getWindow() {
574
    return getScene().getWindow();
575
  }
576
  
573577
  protected Preferences getState() {
574578
    return getOptions().getState();
M src/main/java/com/scrivenvar/MainWindow.java
3131
import static com.scrivenvar.Messages.get;
3232
import com.scrivenvar.definition.DefinitionPane;
33
import com.scrivenvar.editor.EditorPane;
34
import com.scrivenvar.editor.MarkdownEditorPane;
35
import com.scrivenvar.editor.VariableNameInjector;
36
import com.scrivenvar.preview.HTMLPreviewPane;
37
import com.scrivenvar.processors.HTMLPreviewProcessor;
38
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
39
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
40
import com.scrivenvar.processors.MarkdownProcessor;
41
import com.scrivenvar.processors.Processor;
42
import com.scrivenvar.processors.TextChangeProcessor;
43
import com.scrivenvar.processors.VariableProcessor;
44
import com.scrivenvar.service.Options;
45
import com.scrivenvar.util.Action;
46
import com.scrivenvar.util.ActionUtils;
47
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
48
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
49
import com.scrivenvar.yaml.YamlParser;
50
import com.scrivenvar.yaml.YamlTreeAdapter;
51
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
52
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
66
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
67
import java.io.IOException;
68
import java.io.InputStream;
69
import java.util.Map;
70
import java.util.function.Function;
71
import java.util.prefs.Preferences;
72
import javafx.beans.binding.Bindings;
73
import javafx.beans.binding.BooleanBinding;
74
import javafx.beans.property.BooleanProperty;
75
import javafx.beans.property.SimpleBooleanProperty;
76
import javafx.beans.value.ObservableBooleanValue;
77
import javafx.beans.value.ObservableValue;
78
import javafx.collections.ListChangeListener.Change;
79
import javafx.collections.ObservableList;
80
import javafx.event.Event;
81
import javafx.scene.Node;
82
import javafx.scene.Scene;
83
import javafx.scene.control.Alert;
84
import javafx.scene.control.Alert.AlertType;
85
import javafx.scene.control.Menu;
86
import javafx.scene.control.MenuBar;
87
import javafx.scene.control.SplitPane;
88
import javafx.scene.control.Tab;
89
import javafx.scene.control.ToolBar;
90
import javafx.scene.control.TreeView;
91
import javafx.scene.image.Image;
92
import javafx.scene.image.ImageView;
93
import static javafx.scene.input.KeyCode.ESCAPE;
94
import javafx.scene.input.KeyEvent;
95
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
96
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
97
import javafx.scene.layout.BorderPane;
98
import javafx.scene.layout.VBox;
99
import javafx.stage.Window;
100
import javafx.stage.WindowEvent;
101
import org.fxmisc.richtext.StyleClassedTextArea;
102
import static com.scrivenvar.Messages.get;
103
import static com.scrivenvar.Messages.get;
104
import static com.scrivenvar.Messages.get;
105
import static com.scrivenvar.Messages.get;
106
import static com.scrivenvar.Messages.get;
107
import static com.scrivenvar.Messages.get;
108
import static com.scrivenvar.Messages.get;
109
110
/**
111
 * Main window containing a tab pane in the center for file editors.
112
 *
113
 * @author Karl Tauber and White Magic Software, Ltd.
114
 */
115
public class MainWindow {
116
117
  private final Options options = Services.load( Options.class );
118
119
  private Scene scene;
120
121
  private TreeView<String> treeView;
122
  private FileEditorTabPane fileEditorPane;
123
  private DefinitionPane definitionPane;
124
125
  private VariableNameInjector variableNameInjector;
126
127
  private YamlTreeAdapter yamlTreeAdapter;
128
  private YamlParser yamlParser;
129
130
  private MenuBar menuBar;
131
132
  public MainWindow() {
133
    initLayout();
134
    initVariableNameInjector();
135
  }
136
137
  private void initLayout() {
138
    final SplitPane splitPane = new SplitPane(
139
      getDefinitionPane().getNode(),
140
      getFileEditorPane().getNode() );
141
142
    splitPane.setDividerPositions(
143
      getFloat( K_PANE_SPLIT_DEFINITION, .05f ),
144
      getFloat( K_PANE_SPLIT_EDITOR, .95f ) );
145
146
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
147
    final BorderPane borderPane = new BorderPane();
148
    borderPane.setPrefSize( 1024, 800 );
149
    borderPane.setTop( createMenuBar() );
150
    borderPane.setCenter( splitPane );
151
    
152
    final Scene appScene = new Scene( borderPane );
153
    setScene( appScene );
154
    appScene.getStylesheets().add( Constants.STYLESHEET_PREVIEW );
155
    appScene.windowProperty().addListener(
156
      (observable, oldWindow, newWindow) -> {
157
        newWindow.setOnCloseRequest( e -> {
158
          if( !getFileEditorPane().closeAllEditors() ) {
159
            e.consume();
160
          }
161
        } );
162
163
        // Workaround JavaFX bug: deselect menubar if window loses focus.
164
        newWindow.focusedProperty().addListener(
165
          (obs, oldFocused, newFocused) -> {
166
            if( !newFocused ) {
167
              // Send an ESC key event to the menubar
168
              this.menuBar.fireEvent(
169
                new KeyEvent(
170
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
171
                  false, false, false, false ) );
172
            }
173
          } );
174
      } );
175
  }
176
177
  private void initVariableNameInjector() {
178
    setVariableNameInjector( new VariableNameInjector(
179
      getFileEditorPane(),
180
      getDefinitionPane() )
181
    );
182
  }
183
184
  private Window getWindow() {
185
    return getScene().getWindow();
186
  }
187
188
  public Scene getScene() {
189
    return this.scene;
190
  }
191
192
  private void setScene( Scene scene ) {
193
    this.scene = scene;
194
  }
195
196
  /**
197
   * Creates a boolean property that is bound to another boolean value of the
198
   * active editor.
199
   */
200
  private BooleanProperty createActiveBooleanProperty(
201
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
202
203
    final BooleanProperty b = new SimpleBooleanProperty();
204
    final FileEditorTab tab = getActiveFileEditor();
205
206
    if( tab != null ) {
207
      b.bind( func.apply( tab ) );
208
    }
209
210
    getFileEditorPane().activeFileEditorProperty().addListener(
211
      (observable, oldFileEditor, newFileEditor) -> {
212
        b.unbind();
213
214
        if( newFileEditor != null ) {
215
          b.bind( func.apply( newFileEditor ) );
216
        } else {
217
          b.set( false );
218
        }
219
      } );
220
221
    return b;
222
  }
223
224
  //---- File actions -------------------------------------------------------
225
  private void fileNew() {
226
    getFileEditorPane().newEditor();
227
  }
228
229
  private void fileOpen() {
230
    getFileEditorPane().openFileDialog();
231
  }
232
233
  private void fileClose() {
234
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
235
  }
236
237
  private void fileCloseAll() {
238
    getFileEditorPane().closeAllEditors();
239
  }
240
241
  private void fileSave() {
242
    getFileEditorPane().saveEditor( getActiveFileEditor() );
243
  }
244
245
  private void fileSaveAll() {
246
    getFileEditorPane().saveAllEditors();
247
  }
248
249
  private void fileExit() {
250
    final Window window = getWindow();
251
    Event.fireEvent( window,
252
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
253
  }
254
255
  //---- Help actions -------------------------------------------------------
256
  private void helpAbout() {
257
    Alert alert = new Alert( AlertType.INFORMATION );
258
    alert.setTitle( Messages.get( "Dialog.about.title" ) );
259
    alert.setHeaderText( Messages.get( "Dialog.about.header" ) );
260
    alert.setContentText( Messages.get( "Dialog.about.content" ) );
261
    alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) );
262
    alert.initOwner( getWindow() );
263
264
    alert.showAndWait();
265
  }
266
267
  private FileEditorTabPane getFileEditorPane() {
268
    if( this.fileEditorPane == null ) {
269
      this.fileEditorPane = createFileEditorPane();
270
    }
271
272
    return this.fileEditorPane;
273
  }
274
275
  private FileEditorTabPane createFileEditorPane() {
276
    // Create an editor pane to hold file editor tabs.
277
    final FileEditorTabPane editorPane = new FileEditorTabPane();
278
279
    // Make sure the text processor kicks off when new files are opened.
280
    final ObservableList<Tab> tabs = editorPane.getTabs();
281
282
    tabs.addListener( (Change<? extends Tab> change) -> {
283
      while( change.next() ) {
284
        if( change.wasAdded() ) {
285
          // Multiple tabs can be added simultaneously.
286
          for( final Tab tab : change.getAddedSubList() ) {
287
            addListener( (FileEditorTab)tab );
288
          }
289
        }
290
      }
291
    } );
292
293
    // After the processors are in place, restorePreferences the previously closed
294
    // tabs. Adding them will trigger the change event, above.
295
    editorPane.restorePreferences();
296
297
    return editorPane;
298
  }
299
300
  private MarkdownEditorPane getActiveEditor() {
301
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
302
  }
303
304
  private FileEditorTab getActiveFileEditor() {
305
    return getFileEditorPane().getActiveFileEditor();
306
  }
307
308
  /**
309
   * Listens for changes to tabs and their text editors.
310
   *
311
   * @see https://github.com/DaveJarvis/scrivenvar/issues/17
312
   * @see https://github.com/DaveJarvis/scrivenvar/issues/18
313
   *
314
   * @param tab The file editor tab that contains a text editor.
315
   */
316
  private void addListener( FileEditorTab tab ) {
317
    final HTMLPreviewPane previewPane = tab.getPreviewPane();
318
    final EditorPane editorPanel = tab.getEditorPane();
319
    final StyleClassedTextArea editor = editorPanel.getEditor();
320
321
    // TODO: Use a factory based on the filename extension. The default
322
    // extension will be for a markdown file (e.g., on file new).
323
    final Processor<String> hpp = new HTMLPreviewProcessor( previewPane );
324
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
325
    final Processor<String> mp = new MarkdownProcessor( mcrp );
326
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, editor );
327
    final Processor<String> vnp = new VariableProcessor( mcip, getResolvedMap() );
328
    final TextChangeProcessor tp = new TextChangeProcessor( vnp );
329
330
    editorPanel.addChangeListener( tp );
331
    editorPanel.addCaretParagraphListener(
332
      (final ObservableValue<? extends Integer> observable,
333
        final Integer oldValue, final Integer newValue) -> {
334
        
335
        // Kick off the processing chain at the variable processor when the
336
        // cursor changes paragraphs. This might cause some slight duplication
337
        // when the Enter key is pressed.
338
        vnp.processChain( editor.getText() );
339
      } );
340
  }
341
342
  protected DefinitionPane createDefinitionPane() {
343
    return new DefinitionPane( getTreeView() );
344
  }
345
346
  private DefinitionPane getDefinitionPane() {
347
    if( this.definitionPane == null ) {
348
      this.definitionPane = createDefinitionPane();
349
    }
350
351
    return this.definitionPane;
352
  }
353
354
  public MenuBar getMenuBar() {
355
    return menuBar;
356
  }
357
358
  public void setMenuBar( MenuBar menuBar ) {
359
    this.menuBar = menuBar;
360
  }
361
362
  public VariableNameInjector getVariableNameInjector() {
363
    return this.variableNameInjector;
364
  }
365
366
  public void setVariableNameInjector( VariableNameInjector variableNameInjector ) {
367
    this.variableNameInjector = variableNameInjector;
368
  }
369
370
  private float getFloat( final String key, final float defaultValue ) {
371
    return getPreferences().getFloat( key, defaultValue );
372
  }
373
374
  private Preferences getPreferences() {
375
    return getOptions().getState();
376
  }
377
378
  private Options getOptions() {
379
    return this.options;
380
  }
381
382
  private synchronized TreeView<String> getTreeView() throws RuntimeException {
383
    if( this.treeView == null ) {
384
      try {
385
        this.treeView = createTreeView();
386
      } catch( IOException ex ) {
387
388
        // TODO: Pop an error message.
389
        throw new RuntimeException( ex );
390
      }
391
    }
392
393
    return this.treeView;
394
  }
395
396
  private InputStream asStream( final String resource ) {
397
    return getClass().getResourceAsStream( resource );
398
  }
399
400
  private TreeView<String> createTreeView() throws IOException {
401
    // TODO: Associate variable file with path to current file.
402
    return getYamlTreeAdapter().adapt(
403
      asStream( "/com/scrivenvar/variables.yaml" ),
404
      get( "Pane.defintion.node.root.title" )
405
    );
406
  }
407
408
  private Map<String, String> getResolvedMap() {
409
    return getYamlParser().createResolvedMap();
410
  }
411
412
  private YamlTreeAdapter getYamlTreeAdapter() {
413
    if( this.yamlTreeAdapter == null ) {
414
      setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
415
    }
416
417
    return this.yamlTreeAdapter;
418
  }
419
420
  private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
421
    this.yamlTreeAdapter = yamlTreeAdapter;
422
  }
423
424
  private YamlParser getYamlParser() {
425
    if( this.yamlParser == null ) {
426
      setYamlParser( new YamlParser() );
427
    }
428
429
    return this.yamlParser;
430
  }
431
432
  private void setYamlParser( final YamlParser yamlParser ) {
433
    this.yamlParser = yamlParser;
434
  }
435
436
  private Node createMenuBar() {
437
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
438
439
    // File actions
440
    Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
441
    Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
442
    Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
443
    Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
444
    Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
445
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
446
    Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
447
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
448
    Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
449
450
    // Edit actions
451
    Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
452
      e -> getActiveEditor().undo(),
453
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
454
    Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
455
      e -> getActiveEditor().redo(),
456
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
457
458
    // Insert actions
459
    Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
460
      e -> getActiveEditor().surroundSelection( "**", "**" ),
461
      activeFileEditorIsNull );
462
    Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
463
      e -> getActiveEditor().surroundSelection( "*", "*" ),
464
      activeFileEditorIsNull );
465
    Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
466
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
467
      activeFileEditorIsNull );
468
    Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
469
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
470
      activeFileEditorIsNull );
471
    Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
472
      e -> getActiveEditor().surroundSelection( "`", "`" ),
473
      activeFileEditorIsNull );
474
    Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
475
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ),
476
      activeFileEditorIsNull );
477
478
    Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
479
      e -> getActiveEditor().insertLink(),
480
      activeFileEditorIsNull );
481
    Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
482
      e -> getActiveEditor().insertImage(),
483
      activeFileEditorIsNull );
484
485
    final Action[] headers = new Action[ 6 ];
486
487
    // Insert header actions (H1 ... H6)
488
    for( int i = 1; i <= 6; i++ ) {
489
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
490
      final String markup = String.format( "\n\n%s ", hashes );
491
      final String text = Messages.get( "Main.menu.insert.header_" + i );
492
      final String accelerator = "Shortcut+" + i;
493
      final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" );
494
495
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
496
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
497
        activeFileEditorIsNull );
498
    }
499
500
    Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
501
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
502
      activeFileEditorIsNull );
503
    Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
504
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
505
      activeFileEditorIsNull );
506
    Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
507
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
508
      activeFileEditorIsNull );
509
510
    // Help actions
511
    Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
512
513
    //---- MenuBar ----
514
    Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ),
515
      fileNewAction,
516
      fileOpenAction,
517
      null,
518
      fileCloseAction,
519
      fileCloseAllAction,
520
      null,
521
      fileSaveAction,
522
      fileSaveAllAction,
523
      null,
524
      fileExitAction );
525
526
    Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ),
527
      editUndoAction,
528
      editRedoAction );
529
530
    Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ),
531
      insertBoldAction,
532
      insertItalicAction,
533
      insertStrikethroughAction,
534
      insertBlockquoteAction,
535
      insertCodeAction,
536
      insertFencedCodeBlockAction,
537
      null,
538
      insertLinkAction,
539
      insertImageAction,
540
      null,
541
      headers[ 0 ],
542
      headers[ 1 ],
543
      headers[ 2 ],
544
      headers[ 3 ],
545
      headers[ 4 ],
546
      headers[ 5 ],
547
      null,
548
      insertUnorderedListAction,
549
      insertOrderedListAction,
550
      insertHorizontalRuleAction );
551
552
    Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ),
553
      helpAboutAction );
554
555
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
556
557
    //---- ToolBar ----
558
    ToolBar toolBar = ActionUtils.createToolBar(
559
      fileNewAction,
560
      fileOpenAction,
561
      fileSaveAction,
562
      null,
563
      editUndoAction,
564
      editRedoAction,
565
      null,
566
      insertBoldAction,
567
      insertItalicAction,
568
      insertBlockquoteAction,
569
      insertCodeAction,
570
      insertFencedCodeBlockAction,
571
      null,
572
      insertLinkAction,
573
      insertImageAction,
574
      null,
575
      headers[ 0 ],
576
      null,
577
      insertUnorderedListAction,
578
      insertOrderedListAction );
579
580
    return new VBox( menuBar, toolBar );
581
  }
33
import com.scrivenvar.editor.MarkdownEditorPane;
34
import com.scrivenvar.editor.VariableNameInjector;
35
import com.scrivenvar.preview.HTMLPreviewPane;
36
import com.scrivenvar.processors.HTMLPreviewProcessor;
37
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
38
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
39
import com.scrivenvar.processors.MarkdownProcessor;
40
import com.scrivenvar.processors.Processor;
41
import com.scrivenvar.processors.VariableProcessor;
42
import com.scrivenvar.service.Options;
43
import com.scrivenvar.util.Action;
44
import com.scrivenvar.util.ActionUtils;
45
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
46
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
47
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
48
import com.scrivenvar.yaml.YamlParser;
49
import com.scrivenvar.yaml.YamlTreeAdapter;
50
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
51
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
52
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
66
import java.io.IOException;
67
import java.io.InputStream;
68
import java.util.Map;
69
import java.util.function.Function;
70
import java.util.prefs.Preferences;
71
import javafx.beans.binding.Bindings;
72
import javafx.beans.binding.BooleanBinding;
73
import javafx.beans.property.BooleanProperty;
74
import javafx.beans.property.SimpleBooleanProperty;
75
import javafx.beans.value.ObservableBooleanValue;
76
import javafx.beans.value.ObservableValue;
77
import javafx.collections.ListChangeListener.Change;
78
import javafx.collections.ObservableList;
79
import javafx.event.Event;
80
import javafx.scene.Node;
81
import javafx.scene.Scene;
82
import javafx.scene.control.Alert;
83
import javafx.scene.control.Alert.AlertType;
84
import javafx.scene.control.Menu;
85
import javafx.scene.control.MenuBar;
86
import javafx.scene.control.SplitPane;
87
import javafx.scene.control.Tab;
88
import javafx.scene.control.ToolBar;
89
import javafx.scene.control.TreeView;
90
import javafx.scene.image.Image;
91
import javafx.scene.image.ImageView;
92
import static javafx.scene.input.KeyCode.ESCAPE;
93
import javafx.scene.input.KeyEvent;
94
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
95
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
96
import javafx.scene.layout.BorderPane;
97
import javafx.scene.layout.VBox;
98
import javafx.stage.Window;
99
import javafx.stage.WindowEvent;
100
101
/**
102
 * Main window containing a tab pane in the center for file editors.
103
 *
104
 * @author Karl Tauber and White Magic Software, Ltd.
105
 */
106
public class MainWindow {
107
108
  private final Options options = Services.load( Options.class );
109
110
  private Scene scene;
111
112
  private TreeView<String> treeView;
113
  private DefinitionPane definitionPane;
114
  private FileEditorTabPane fileEditorPane;
115
  private HTMLPreviewPane previewPane;
116
117
  private VariableNameInjector variableNameInjector;
118
119
  private YamlTreeAdapter yamlTreeAdapter;
120
  private YamlParser yamlParser;
121
122
  private MenuBar menuBar;
123
124
  public MainWindow() {
125
    initLayout();
126
    initTabAddedListener();
127
    restorePreferences();
128
    initTabChangeListener();
129
    initVariableNameInjector();
130
  }
131
132
  private void initLayout() {
133
    final SplitPane splitPane = new SplitPane(
134
      getDefinitionPane().getNode(),
135
      getFileEditorPane().getNode(),
136
      getPreviewPane().getNode() );
137
138
    splitPane.setDividerPositions(
139
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
140
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
141
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
142
143
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
144
    final BorderPane borderPane = new BorderPane();
145
    borderPane.setPrefSize( 1024, 800 );
146
    borderPane.setTop( createMenuBar() );
147
    borderPane.setCenter( splitPane );
148
149
    final Scene appScene = new Scene( borderPane );
150
    setScene( appScene );
151
    appScene.getStylesheets().add( Constants.STYLESHEET_PREVIEW );
152
    appScene.windowProperty().addListener(
153
      (observable, oldWindow, newWindow) -> {
154
        newWindow.setOnCloseRequest( e -> {
155
          if( !getFileEditorPane().closeAllEditors() ) {
156
            e.consume();
157
          }
158
        } );
159
160
        // Workaround JavaFX bug: deselect menubar if window loses focus.
161
        newWindow.focusedProperty().addListener(
162
          (obs, oldFocused, newFocused) -> {
163
            if( !newFocused ) {
164
              // Send an ESC key event to the menubar
165
              this.menuBar.fireEvent(
166
                new KeyEvent(
167
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
168
                  false, false, false, false ) );
169
            }
170
          } );
171
      } );
172
  }
173
174
  private void initVariableNameInjector() {
175
    setVariableNameInjector( new VariableNameInjector(
176
      getFileEditorPane(),
177
      getDefinitionPane() )
178
    );
179
  }
180
181
  private Window getWindow() {
182
    return getScene().getWindow();
183
  }
184
185
  public Scene getScene() {
186
    return this.scene;
187
  }
188
189
  private void setScene( Scene scene ) {
190
    this.scene = scene;
191
  }
192
193
  /**
194
   * Creates a boolean property that is bound to another boolean value of the
195
   * active editor.
196
   */
197
  private BooleanProperty createActiveBooleanProperty(
198
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
199
200
    final BooleanProperty b = new SimpleBooleanProperty();
201
    final FileEditorTab tab = getActiveFileEditor();
202
203
    if( tab != null ) {
204
      b.bind( func.apply( tab ) );
205
    }
206
207
    getFileEditorPane().activeFileEditorProperty().addListener(
208
      (observable, oldFileEditor, newFileEditor) -> {
209
        b.unbind();
210
211
        if( newFileEditor != null ) {
212
          b.bind( func.apply( newFileEditor ) );
213
        } else {
214
          b.set( false );
215
        }
216
      } );
217
218
    return b;
219
  }
220
221
  //---- File actions -------------------------------------------------------
222
  private void fileNew() {
223
    getFileEditorPane().newEditor();
224
  }
225
226
  private void fileOpen() {
227
    getFileEditorPane().openFileDialog();
228
  }
229
230
  private void fileClose() {
231
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
232
  }
233
234
  private void fileCloseAll() {
235
    getFileEditorPane().closeAllEditors();
236
  }
237
238
  private void fileSave() {
239
    getFileEditorPane().saveEditor( getActiveFileEditor() );
240
  }
241
242
  private void fileSaveAll() {
243
    getFileEditorPane().saveAllEditors();
244
  }
245
246
  private void fileExit() {
247
    final Window window = getWindow();
248
    Event.fireEvent( window,
249
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
250
  }
251
252
  //---- Help actions -------------------------------------------------------
253
  private void helpAbout() {
254
    Alert alert = new Alert( AlertType.INFORMATION );
255
    alert.setTitle( Messages.get( "Dialog.about.title" ) );
256
    alert.setHeaderText( Messages.get( "Dialog.about.header" ) );
257
    alert.setContentText( Messages.get( "Dialog.about.content" ) );
258
    alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) );
259
    alert.initOwner( getWindow() );
260
261
    alert.showAndWait();
262
  }
263
264
  private FileEditorTabPane getFileEditorPane() {
265
    if( this.fileEditorPane == null ) {
266
      this.fileEditorPane = createFileEditorPane();
267
    }
268
269
    return this.fileEditorPane;
270
  }
271
272
  /**
273
   * Create an editor pane to hold file editor tabs.
274
   *
275
   * @return A new instance, never null.
276
   */
277
  private FileEditorTabPane createFileEditorPane() {
278
    return new FileEditorTabPane();
279
  }
280
281
  /**
282
   * Reloads the preferences from the previous load.
283
   */
284
  private void restorePreferences() {
285
    getFileEditorPane().restorePreferences();
286
  }
287
288
  private void initTabAddedListener() {
289
    final FileEditorTabPane editorPane = getFileEditorPane();
290
291
    // Make sure the text processor kicks off when new files are opened.
292
    final ObservableList<Tab> tabs = editorPane.getTabs();
293
294
    // Update the preview pane on tab changes.
295
    tabs.addListener( (final Change<? extends Tab> change) -> {
296
      while( change.next() ) {
297
        if( change.wasAdded() ) {
298
          // Multiple tabs can be added simultaneously.
299
          for( final Tab newTab : change.getAddedSubList() ) {
300
            final FileEditorTab tab = (FileEditorTab)newTab;
301
302
            initTextChangeListener( tab );
303
            initCaretParagraphListener( tab );
304
            process( tab );
305
          }
306
        }
307
      }
308
    } );
309
  }
310
311
  /**
312
   * Listen for tab changes.
313
   */
314
  private void initTabChangeListener() {
315
    final FileEditorTabPane editorPane = getFileEditorPane();
316
317
    // Update the preview pane changing tabs.
318
    editorPane.addTabChangeListener(
319
      (ObservableValue<? extends Tab> tabPane,
320
        final Tab oldTab, final Tab newTab) -> {
321
322
        final FileEditorTab tab = (FileEditorTab)newTab;
323
324
        if( tab != null ) {
325
          // When a new tab is selected, ensure that the base path to images
326
          // is set correctly.
327
          getPreviewPane().setPath( tab.getPath() );
328
          process( tab );
329
        }
330
      } );
331
  }
332
333
  private void initTextChangeListener( final FileEditorTab tab ) {
334
    tab.addTextChangeListener( (ObservableValue<? extends String> editor,
335
      final String oldValue, final String newValue) -> {
336
      process( tab );
337
    } );
338
  }
339
340
  private void initCaretParagraphListener( final FileEditorTab tab ) {
341
    tab.addCaretParagraphListener( (ObservableValue<? extends Integer> editor,
342
      final Integer oldValue, final Integer newValue) -> {
343
      process( tab );
344
    } );
345
  }
346
347
  /**
348
   * Called whenever the preview pane becomes out of sync with the file editor
349
   * tab. This can be called when the text changes, the caret paragraph changes,
350
   * or the file tab changes.
351
   *
352
   * @param tab The file editor tab that has been changed in some fashion.
353
   */
354
  private void process( final FileEditorTab tab ) {
355
    // TODO: Use a factory based on the filename extension. The default
356
    // extension will be for a markdown file (e.g., on file new).
357
    final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() );
358
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
359
    final Processor<String> mp = new MarkdownProcessor( mcrp );
360
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
361
    final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
362
    
363
    vp.processChain( tab.getEditorText() );
364
  }
365
366
  private MarkdownEditorPane getActiveEditor() {
367
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
368
  }
369
370
  private FileEditorTab getActiveFileEditor() {
371
    return getFileEditorPane().getActiveFileEditor();
372
  }
373
374
  protected DefinitionPane createDefinitionPane() {
375
    return new DefinitionPane( getTreeView() );
376
  }
377
378
  private DefinitionPane getDefinitionPane() {
379
    if( this.definitionPane == null ) {
380
      this.definitionPane = createDefinitionPane();
381
    }
382
383
    return this.definitionPane;
384
  }
385
386
  public MenuBar getMenuBar() {
387
    return this.menuBar;
388
  }
389
390
  public void setMenuBar( MenuBar menuBar ) {
391
    this.menuBar = menuBar;
392
  }
393
394
  public VariableNameInjector getVariableNameInjector() {
395
    return this.variableNameInjector;
396
  }
397
398
  public void setVariableNameInjector( VariableNameInjector variableNameInjector ) {
399
    this.variableNameInjector = variableNameInjector;
400
  }
401
402
  private float getFloat( final String key, final float defaultValue ) {
403
    return getPreferences().getFloat( key, defaultValue );
404
  }
405
406
  private Preferences getPreferences() {
407
    return getOptions().getState();
408
  }
409
410
  private Options getOptions() {
411
    return this.options;
412
  }
413
414
  private synchronized TreeView<String> getTreeView() throws RuntimeException {
415
    if( this.treeView == null ) {
416
      try {
417
        this.treeView = createTreeView();
418
      } catch( IOException ex ) {
419
420
        // TODO: Pop an error message.
421
        throw new RuntimeException( ex );
422
      }
423
    }
424
425
    return this.treeView;
426
  }
427
428
  private InputStream asStream( final String resource ) {
429
    return getClass().getResourceAsStream( resource );
430
  }
431
432
  private TreeView<String> createTreeView() throws IOException {
433
    // TODO: Associate variable file with path to current file.
434
    return getYamlTreeAdapter().adapt(
435
      asStream( "/com/scrivenvar/variables.yaml" ),
436
      get( "Pane.defintion.node.root.title" )
437
    );
438
  }
439
440
  private Map<String, String> getResolvedMap() {
441
    return getYamlParser().createResolvedMap();
442
  }
443
444
  private YamlTreeAdapter getYamlTreeAdapter() {
445
    if( this.yamlTreeAdapter == null ) {
446
      setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
447
    }
448
449
    return this.yamlTreeAdapter;
450
  }
451
452
  private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
453
    this.yamlTreeAdapter = yamlTreeAdapter;
454
  }
455
456
  private YamlParser getYamlParser() {
457
    if( this.yamlParser == null ) {
458
      setYamlParser( new YamlParser() );
459
    }
460
461
    return this.yamlParser;
462
  }
463
464
  private void setYamlParser( final YamlParser yamlParser ) {
465
    this.yamlParser = yamlParser;
466
  }
467
468
  private Node createMenuBar() {
469
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
470
471
    // File actions
472
    Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
473
    Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
474
    Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
475
    Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
476
    Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
477
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
478
    Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
479
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
480
    Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
481
482
    // Edit actions
483
    Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
484
      e -> getActiveEditor().undo(),
485
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
486
    Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
487
      e -> getActiveEditor().redo(),
488
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
489
490
    // Insert actions
491
    Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
492
      e -> getActiveEditor().surroundSelection( "**", "**" ),
493
      activeFileEditorIsNull );
494
    Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
495
      e -> getActiveEditor().surroundSelection( "*", "*" ),
496
      activeFileEditorIsNull );
497
    Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
498
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
499
      activeFileEditorIsNull );
500
    Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
501
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
502
      activeFileEditorIsNull );
503
    Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
504
      e -> getActiveEditor().surroundSelection( "`", "`" ),
505
      activeFileEditorIsNull );
506
    Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
507
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ),
508
      activeFileEditorIsNull );
509
510
    Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
511
      e -> getActiveEditor().insertLink(),
512
      activeFileEditorIsNull );
513
    Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
514
      e -> getActiveEditor().insertImage(),
515
      activeFileEditorIsNull );
516
517
    final Action[] headers = new Action[ 6 ];
518
519
    // Insert header actions (H1 ... H6)
520
    for( int i = 1; i <= 6; i++ ) {
521
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
522
      final String markup = String.format( "\n\n%s ", hashes );
523
      final String text = Messages.get( "Main.menu.insert.header_" + i );
524
      final String accelerator = "Shortcut+" + i;
525
      final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" );
526
527
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
528
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
529
        activeFileEditorIsNull );
530
    }
531
532
    Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
533
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
534
      activeFileEditorIsNull );
535
    Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
536
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
537
      activeFileEditorIsNull );
538
    Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
539
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
540
      activeFileEditorIsNull );
541
542
    // Help actions
543
    Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
544
545
    //---- MenuBar ----
546
    Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ),
547
      fileNewAction,
548
      fileOpenAction,
549
      null,
550
      fileCloseAction,
551
      fileCloseAllAction,
552
      null,
553
      fileSaveAction,
554
      fileSaveAllAction,
555
      null,
556
      fileExitAction );
557
558
    Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ),
559
      editUndoAction,
560
      editRedoAction );
561
562
    Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ),
563
      insertBoldAction,
564
      insertItalicAction,
565
      insertStrikethroughAction,
566
      insertBlockquoteAction,
567
      insertCodeAction,
568
      insertFencedCodeBlockAction,
569
      null,
570
      insertLinkAction,
571
      insertImageAction,
572
      null,
573
      headers[ 0 ],
574
      headers[ 1 ],
575
      headers[ 2 ],
576
      headers[ 3 ],
577
      headers[ 4 ],
578
      headers[ 5 ],
579
      null,
580
      insertUnorderedListAction,
581
      insertOrderedListAction,
582
      insertHorizontalRuleAction );
583
584
    Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ),
585
      helpAboutAction );
586
587
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
588
589
    //---- ToolBar ----
590
    ToolBar toolBar = ActionUtils.createToolBar(
591
      fileNewAction,
592
      fileOpenAction,
593
      fileSaveAction,
594
      null,
595
      editUndoAction,
596
      editRedoAction,
597
      null,
598
      insertBoldAction,
599
      insertItalicAction,
600
      insertBlockquoteAction,
601
      insertCodeAction,
602
      insertFencedCodeBlockAction,
603
      null,
604
      insertLinkAction,
605
      insertImageAction,
606
      null,
607
      headers[ 0 ],
608
      null,
609
      insertUnorderedListAction,
610
      insertOrderedListAction );
611
612
    return new VBox( menuBar, toolBar );
613
  }
614
615
  private synchronized HTMLPreviewPane getPreviewPane() {
616
    if( this.previewPane == null ) {
617
      this.previewPane = new HTMLPreviewPane();
618
    }
619
620
    return this.previewPane;
621
  }
622
582623
}
583624
M src/main/java/com/scrivenvar/editor/EditorPane.java
9494
   * @param listener Receives editor text change events.
9595
   */
96
  public void addChangeListener( final ChangeListener<? super String> listener ) {
96
  public void addTextChangeListener( final ChangeListener<? super String> listener ) {
9797
    getEditor().textProperty().addListener( listener );
9898
  }
9999
100100
  /**
101101
   * Call to listen for when the caret moves to another paragraph.
102102
   * 
103103
   * @param listener Receives paragraph change events.
104104
   */
105
  public void addCaretParagraphListener( final ChangeListener<? super Integer> listener ) {
105
  public void addCaretParagraphListener(
106
    final ChangeListener<? super Integer> listener ) {
106107
    getEditor().currentParagraphProperty().addListener( listener );
107108
  }
108
109
  
109110
  /**
110111
   * This method adds listeners to editor events.
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
4646
4747
  private final WebView webView = new WebView();
48
  private String html;
4948
  private Path path;
5049
5150
  /**
5251
   * Creates a new preview pane that can scroll to the caret position within the
5352
   * document.
54
   *
55
   * @param path The base path for loading resources, such as images.
5653
   */
57
  public HTMLPreviewPane( final Path path ) {
58
    setPath( path );
54
  public HTMLPreviewPane() {
5955
    initListeners();
6056
    initTraversal();
...
158154
  }
159155
160
  private void setPath( final Path path ) {
156
  public void setPath( final Path path ) {
161157
    this.path = path;
162158
  }
163159
  
160
  /**
161
   * Content to embed in a panel.
162
   * 
163
   * @return The content to display to the user.
164
   */
164165
  public Node getNode() {
165166
    return getWebView();
M src/main/java/com/scrivenvar/processors/MarkdownCaretInsertionProcessor.java
3030
import static com.scrivenvar.Constants.MD_CARET_POSITION;
3131
import static java.lang.Character.isLetter;
32
import org.fxmisc.richtext.model.TextEditingArea;
3332
3433
/**
...
4140
public class MarkdownCaretInsertionProcessor extends AbstractProcessor<String> {
4241
43
  private TextEditingArea editor;
42
  private final int caretPosition;
4443
4544
  /**
4645
   * Constructs a processor capable of inserting a caret marker into Markdown.
4746
   *
4847
   * @param processor The next processor in the chain.
49
   * @param editor The editor that has a caret with a position in the text.
48
   * @param position The caret's current position in the text, cannot be null.
5049
   */
5150
  public MarkdownCaretInsertionProcessor(
52
    final Processor<String> processor, final TextEditingArea editor ) {
51
    final Processor<String> processor, final int position ) {
5352
    super( processor );
54
    setEditor( editor );
53
    this.caretPosition = position;
5554
  }
5655
...
7473
      offset++;
7574
    }
75
    
76
    // TODO: Ensure that the caret position is outside of an element, 
77
    // so that a caret inserted in the image doesn't corrupt it. Such as:
78
    //
79
    // ![Screenshot](images/scr|eenshot.png)
7680
7781
    // Insert the caret position into the Markdown text, but don't interfere
...
8791
   */
8892
  private int getCaretPosition() {
89
    return getEditor().getCaretPosition();
90
  }
91
92
  /**
93
   * Returns the editor that has a caret position.
94
   *
95
   * @return An editor with a caret position.
96
   */
97
  private TextEditingArea getEditor() {
98
    return this.editor;
99
  }
100
101
  private void setEditor( final TextEditingArea editor ) {
102
    this.editor = editor;
93
    return this.caretPosition;
10394
  }
10495
}
D src/main/java/com/scrivenvar/processors/TextChangeProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import javafx.beans.value.ChangeListener;
31
import javafx.beans.value.ObservableValue;
32
33
/**
34
 * Responsible for forwarding change events to the document process chain. This
35
 * class isolates knowledge of the change events from the other processors.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public class TextChangeProcessor extends AbstractProcessor<String>
40
  implements ChangeListener<String> {
41
42
  /**
43
   * Constructs a new text processor that listens for changes to text and then
44
   * injects them into the processing chain.
45
   *
46
   * @param successor Usually the HTML Preview Processor.
47
   */
48
  public TextChangeProcessor( final Processor<String> successor ) {
49
    super( successor );
50
  }
51
52
  /**
53
   * Called when the text editor changes.
54
   *
55
   * @param observable Unused.
56
   * @param oldValue The value before being changed (unused).
57
   * @param newValue The value after being changed (passed to processChain).
58
   */
59
  @Override
60
  public void changed(
61
    final ObservableValue<? extends String> observable,
62
    final String oldValue,
63
    final String newValue ) {
64
    processChain( newValue );
65
  }
66
67
  /**
68
   * Performs no processing.
69
   *
70
   * @param t Returned value.
71
   *
72
   * @return t, without any processing.
73
   */
74
  @Override
75
  public String processLink( String t ) {
76
    return t;
77
  }
78
}
791
M src/main/java/com/scrivenvar/util/StageState.java
4242
  public static final String K_PANE_SPLIT_DEFINITION = "pane.split.definition";
4343
  public static final String K_PANE_SPLIT_EDITOR = "pane.split.editor";
44
  public static final String K_PANE_SPLIT_PREVIEW = "pane.split.preview";
4445
4546
  private final Stage stage;