Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
1
version = '0.5'
1
version = '1.0.0'
22
33
apply plugin: 'java'
...
2929
  compile group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.8.4'
3030
  compile group: 'org.yaml', name: 'snakeyaml', version: '1.17'
31
  compile group: 'com.ximpleware', name: 'vtd-xml', version: '2.13'
32
  compile group: 'net.sf.saxon', name: 'Saxon-HE', version: '9.7.0-14'
3133
  compile group: 'com.googlecode.juniversalchardet', name: 'juniversalchardet', version: '1.0.3'
3234
  compile group: 'org.apache.commons', name: 'commons-configuration2', version: '2.1'
M src/main/java/com/scrivenvar/FileEditorTab.java
2828
import com.scrivenvar.editors.EditorPane;
2929
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
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
81
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
82
    updateTab();
83
84
    setOnSelectionChanged( e -> {
85
      if( isSelected() ) {
86
        Platform.runLater( () -> activated() );
87
      }
88
    } );
89
  }
90
91
  private void updateTab() {
92
    setText( getTabTitle() );
93
    setGraphic( getModifiedMark() );
94
    setTooltip( getTabTooltip() );
95
  }
96
97
  /**
98
   * Returns the base filename (without the directory names).
99
   *
100
   * @return The untitled text if the path hasn't been set.
101
   */
102
  private String getTabTitle() {
103
    final Path filePath = getPath();
104
105
    return (filePath == null)
106
      ? Messages.get( "FileEditor.untitled" )
107
      : filePath.getFileName().toString();
108
  }
109
110
  /**
111
   * Returns the full filename represented by the path.
112
   *
113
   * @return The untitled text if the path hasn't been set.
114
   */
115
  private Tooltip getTabTooltip() {
116
    final Path filePath = getPath();
117
118
    return (filePath == null)
119
      ? null
120
      : new Tooltip( filePath.toString() );
121
  }
122
123
  /**
124
   * Returns a marker to indicate whether the file has been modified.
125
   *
126
   * @return "*" when the file has changed; otherwise null.
127
   */
128
  private Text getModifiedMark() {
129
    return isModified() ? new Text( "*" ) : null;
130
  }
131
132
  /**
133
   * Called when the user switches tab.
134
   */
135
  private void activated() {
136
    // Tab is closed or no longer active.
137
    if( getTabPane() == null || !isSelected() ) {
138
      return;
139
    }
140
141
    // Switch to the tab without loading if the contents are already in memory.
142
    if( getContent() != null ) {
143
      getEditorPane().requestFocus();
144
      return;
145
    }
146
147
    // Load the text and update the preview before the undo manager.
148
    load();
149
150
    // Track undo requests -- can only be called *after* load.
151
    initUndoManager();
152
    initLayout();
153
    initFocus();
154
  }
155
156
  private void initLayout() {
157
    setContent( getScrollPane() );
158
  }
159
160
  private Node getScrollPane() {
161
    return getEditorPane().getScrollPane();
162
  }
163
164
  private void initFocus() {
165
    getEditorPane().requestFocus();
166
  }
167
168
  private void initUndoManager() {
169
    final UndoManager undoManager = getUndoManager();
170
171
    // Clear undo history after first load.
172
    undoManager.forgetHistory();
173
174
    // Bind the editor undo manager to the properties.
175
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
176
    canUndo.bind( undoManager.undoAvailableProperty() );
177
    canRedo.bind( undoManager.redoAvailableProperty() );
178
  }
179
180
  /**
181
   * Returns the index into the text where the caret blinks happily away.
182
   *
183
   * @return A number from 0 to the editor's document text length.
184
   */
185
  public int getCaretPosition() {
186
    return getEditorPane().getEditor().getCaretPosition();
187
  }
188
  
189
  /**
190
   * Returns true if the given path exactly matches this tab's path.
191
   *
192
   * @param check The path to compare against.
193
   *
194
   * @return true The paths are the same.
195
   */
196
  public boolean isPath( final Path check ) {
197
    final Path filePath = getPath();
198
199
    return filePath == null ? false : filePath.equals( check );
200
  }
201
202
  /**
203
   * Reads the entire file contents from the path associated with this tab.
204
   */
205
  private void load() {
206
    final Path filePath = getPath();
207
208
    if( filePath != null ) {
209
      try {
210
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
211
      } catch( Exception ex ) {
212
        alert(
213
          "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
214
        );
215
      }
216
    }
217
  }
218
219
  /**
220
   * Saves the entire file contents from the path associated with this tab.
221
   *
222
   * @return true The file has been saved.
223
   */
224
  public boolean save() {
225
    try {
226
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
227
      getEditorPane().getUndoManager().mark();
228
      return true;
229
    } catch( Exception ex ) {
230
      return alert(
231
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
232
      );
233
    }
234
  }
235
236
  /**
237
   * Creates an alert dialog and waits for it to close.
238
   *
239
   * @param titleKey Resource bundle key for the alert dialog title.
240
   * @param messageKey Resource bundle key for the alert dialog message.
241
   * @param e The unexpected happening.
242
   *
243
   * @return false
244
   */
245
  private boolean alert(
246
    final String titleKey, final String messageKey, final Exception e ) {
247
    final AlertService service = getAlertService();
248
249
    final AlertMessage message = service.createAlertMessage(
250
      Messages.get( titleKey ),
251
      Messages.get( messageKey ),
252
      getPath(),
253
      e.getMessage()
254
    );
255
256
    service.createAlertError( message ).showAndWait();
257
    return false;
258
  }
259
260
  /**
261
   * Returns a best guess at the file encoding. If the encoding could not be
262
   * detected, this will return the default charset for the JVM.
263
   *
264
   * @param bytes The bytes to perform character encoding detection.
265
   *
266
   * @return The character encoding.
267
   */
268
  private Charset detectEncoding( final byte[] bytes ) {
269
    final UniversalDetector detector = new UniversalDetector( null );
270
    detector.handleData( bytes, 0, bytes.length );
271
    detector.dataEnd();
272
273
    final String charset = detector.getDetectedCharset();
274
    final Charset charEncoding = charset == null
275
      ? Charset.defaultCharset()
276
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
277
278
    detector.reset();
279
280
    return charEncoding;
281
  }
282
283
  /**
284
   * Converts the given string to an array of bytes using the encoding that was
285
   * originally detected (if any) and associated with this file.
286
   *
287
   * @param text The text to convert into the original file encoding.
288
   *
289
   * @return A series of bytes ready for writing to a file.
290
   */
291
  private byte[] asBytes( final String text ) {
292
    return text.getBytes( getEncoding() );
293
  }
294
295
  /**
296
   * Converts the given bytes into a Java String. This will call setEncoding
297
   * with the encoding detected by the CharsetDetector.
298
   *
299
   * @param text The text of unknown character encoding.
300
   *
301
   * @return The text, in its auto-detected encoding, as a String.
302
   */
303
  private String asString( final byte[] text ) {
304
    setEncoding( detectEncoding( text ) );
305
    return new String( text, getEncoding() );
306
  }
307
308
  Path getPath() {
309
    return this.path;
310
  }
311
312
  void setPath( final Path path ) {
313
    this.path = path;
314
  }
315
316
  public boolean isModified() {
317
    return this.modified.get();
318
  }
319
320
  ReadOnlyBooleanProperty modifiedProperty() {
321
    return this.modified.getReadOnlyProperty();
322
  }
323
324
  BooleanProperty canUndoProperty() {
325
    return this.canUndo;
326
  }
327
328
  BooleanProperty canRedoProperty() {
329
    return this.canRedo;
330
  }
331
332
  private UndoManager getUndoManager() {
333
    return getEditorPane().getUndoManager();
334
  }
335
336
  /**
337
   * Forwards the request to the editor pane.
338
   *
339
   * @param <T> The type of event listener to add.
340
   * @param <U> The type of consumer to add.
341
   * @param event The event that should trigger updates to the listener.
342
   * @param consumer The listener to receive update events.
343
   */
344
  public <T extends Event, U extends T> void addEventListener(
345
    final EventPattern<? super T, ? extends U> event,
346
    final Consumer<? super U> consumer ) {
347
    getEditorPane().addEventListener( event, consumer );
348
  }
349
350
  /**
351
   * Forwards to the editor pane's listeners for keyboard events.
352
   *
353
   * @param map The new input map to replace the existing keyboard listener.
354
   */
355
  public void addEventListener( final InputMap<InputEvent> map ) {
356
    getEditorPane().addEventListener( map );
357
  }
358
359
  /**
360
   * Forwards to the editor pane's listeners for keyboard events.
361
   *
362
   * @param map The existing input map to remove from the keyboard listeners.
363
   */
364
  public void removeEventListener( final InputMap<InputEvent> map ) {
365
    getEditorPane().removeEventListener( map );
366
  }
367
368
  /**
369
   * Forwards to the editor pane's listeners for text change events.
370
   *
371
   * @param listener The listener to notify when the text changes.
372
   */
373
  public void addTextChangeListener( final ChangeListener<String> listener ) {
374
    getEditorPane().addTextChangeListener( listener );
375
  }
376
377
  /**
378
   * Forwards to the editor pane's listeners for caret paragraph change events.
379
   *
380
   * @param listener The listener to notify when the caret changes paragraphs.
381
   */
382
  public void addCaretParagraphListener( final ChangeListener<Integer> listener ) {
383
    getEditorPane().addCaretParagraphListener( listener );
384
  }
385
386
  /**
387
   * Forwards the request to the editor pane.
388
   *
389
   * @return The text to process.
390
   */
391
  public String getEditorText() {
392
    return getEditorPane().getText();
393
  }
394
395
  /**
396
   * Returns the editor pane, or creates one if it doesn't yet exist.
397
   *
398
   * @return The editor pane, never null.
399
   */
400
  public EditorPane getEditorPane() {
401
    if( this.editorPane == null ) {
402
      this.editorPane = new MarkdownEditorPane();
403
    }
404
405
    return this.editorPane;
406
  }
407
408
  private AlertService getAlertService() {
409
    return this.alertService;
410
  }
411
412
  private Options getOptions() {
413
    return this.options;
30
import com.scrivenvar.service.events.AlertMessage;
31
import com.scrivenvar.service.events.AlertService;
32
import java.nio.charset.Charset;
33
import java.nio.file.Files;
34
import java.nio.file.Path;
35
import static java.util.Locale.ENGLISH;
36
import java.util.function.Consumer;
37
import javafx.application.Platform;
38
import javafx.beans.binding.Bindings;
39
import javafx.beans.property.BooleanProperty;
40
import javafx.beans.property.ReadOnlyBooleanProperty;
41
import javafx.beans.property.ReadOnlyBooleanWrapper;
42
import javafx.beans.property.SimpleBooleanProperty;
43
import javafx.beans.value.ChangeListener;
44
import javafx.event.Event;
45
import javafx.scene.Node;
46
import javafx.scene.control.Tab;
47
import javafx.scene.control.Tooltip;
48
import javafx.scene.input.InputEvent;
49
import javafx.scene.text.Text;
50
import org.fxmisc.undo.UndoManager;
51
import org.fxmisc.wellbehaved.event.EventPattern;
52
import org.fxmisc.wellbehaved.event.InputMap;
53
import org.mozilla.universalchardet.UniversalDetector;
54
55
/**
56
 * Editor for a single file.
57
 *
58
 * @author Karl Tauber and White Magic Software, Ltd.
59
 */
60
public final class FileEditorTab extends Tab {
61
62
  private final AlertService alertService = Services.load( AlertService.class );
63
  private EditorPane editorPane;
64
65
  /**
66
   * Character encoding used by the file (or default encoding if none found).
67
   */
68
  private Charset encoding;
69
70
  private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper();
71
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
72
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
73
  private Path path;
74
75
  FileEditorTab( final Path path ) {
76
    setPath( path );
77
78
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
79
    updateTab();
80
81
    setOnSelectionChanged( e -> {
82
      if( isSelected() ) {
83
        Platform.runLater( () -> activated() );
84
      }
85
    } );
86
  }
87
88
  private void updateTab() {
89
    setText( getTabTitle() );
90
    setGraphic( getModifiedMark() );
91
    setTooltip( getTabTooltip() );
92
  }
93
94
  /**
95
   * Returns the base filename (without the directory names).
96
   *
97
   * @return The untitled text if the path hasn't been set.
98
   */
99
  private String getTabTitle() {
100
    final Path filePath = getPath();
101
102
    return (filePath == null)
103
      ? Messages.get( "FileEditor.untitled" )
104
      : filePath.getFileName().toString();
105
  }
106
107
  /**
108
   * Returns the full filename represented by the path.
109
   *
110
   * @return The untitled text if the path hasn't been set.
111
   */
112
  private Tooltip getTabTooltip() {
113
    final Path filePath = getPath();
114
115
    return (filePath == null)
116
      ? null
117
      : new Tooltip( filePath.toString() );
118
  }
119
120
  /**
121
   * Returns a marker to indicate whether the file has been modified.
122
   *
123
   * @return "*" when the file has changed; otherwise null.
124
   */
125
  private Text getModifiedMark() {
126
    return isModified() ? new Text( "*" ) : null;
127
  }
128
129
  /**
130
   * Called when the user switches tab.
131
   */
132
  private void activated() {
133
    // Tab is closed or no longer active.
134
    if( getTabPane() == null || !isSelected() ) {
135
      return;
136
    }
137
138
    // Switch to the tab without loading if the contents are already in memory.
139
    if( getContent() != null ) {
140
      getEditorPane().requestFocus();
141
      return;
142
    }
143
144
    // Load the text and update the preview before the undo manager.
145
    load();
146
147
    // Track undo requests -- can only be called *after* load.
148
    initUndoManager();
149
    initLayout();
150
    initFocus();
151
  }
152
153
  private void initLayout() {
154
    setContent( getScrollPane() );
155
  }
156
157
  private Node getScrollPane() {
158
    return getEditorPane().getScrollPane();
159
  }
160
161
  private void initFocus() {
162
    getEditorPane().requestFocus();
163
  }
164
165
  private void initUndoManager() {
166
    final UndoManager undoManager = getUndoManager();
167
168
    // Clear undo history after first load.
169
    undoManager.forgetHistory();
170
171
    // Bind the editor undo manager to the properties.
172
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
173
    canUndo.bind( undoManager.undoAvailableProperty() );
174
    canRedo.bind( undoManager.redoAvailableProperty() );
175
  }
176
177
  /**
178
   * Returns the index into the text where the caret blinks happily away.
179
   *
180
   * @return A number from 0 to the editor's document text length.
181
   */
182
  public int getCaretPosition() {
183
    return getEditorPane().getEditor().getCaretPosition();
184
  }
185
  
186
  /**
187
   * Returns true if the given path exactly matches this tab's path.
188
   *
189
   * @param check The path to compare against.
190
   *
191
   * @return true The paths are the same.
192
   */
193
  public boolean isPath( final Path check ) {
194
    final Path filePath = getPath();
195
196
    return filePath == null ? false : filePath.equals( check );
197
  }
198
199
  /**
200
   * Reads the entire file contents from the path associated with this tab.
201
   */
202
  private void load() {
203
    final Path filePath = getPath();
204
205
    if( filePath != null ) {
206
      try {
207
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
208
      } catch( Exception ex ) {
209
        alert(
210
          "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
211
        );
212
      }
213
    }
214
  }
215
216
  /**
217
   * Saves the entire file contents from the path associated with this tab.
218
   *
219
   * @return true The file has been saved.
220
   */
221
  public boolean save() {
222
    try {
223
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
224
      getEditorPane().getUndoManager().mark();
225
      return true;
226
    } catch( Exception ex ) {
227
      return alert(
228
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
229
      );
230
    }
231
  }
232
233
  /**
234
   * Creates an alert dialog and waits for it to close.
235
   *
236
   * @param titleKey Resource bundle key for the alert dialog title.
237
   * @param messageKey Resource bundle key for the alert dialog message.
238
   * @param e The unexpected happening.
239
   *
240
   * @return false
241
   */
242
  private boolean alert(
243
    final String titleKey, final String messageKey, final Exception e ) {
244
    final AlertService service = getAlertService();
245
246
    final AlertMessage message = service.createAlertMessage(
247
      Messages.get( titleKey ),
248
      Messages.get( messageKey ),
249
      getPath(),
250
      e.getMessage()
251
    );
252
253
    service.createAlertError( message ).showAndWait();
254
    return false;
255
  }
256
257
  /**
258
   * Returns a best guess at the file encoding. If the encoding could not be
259
   * detected, this will return the default charset for the JVM.
260
   *
261
   * @param bytes The bytes to perform character encoding detection.
262
   *
263
   * @return The character encoding.
264
   */
265
  private Charset detectEncoding( final byte[] bytes ) {
266
    final UniversalDetector detector = new UniversalDetector( null );
267
    detector.handleData( bytes, 0, bytes.length );
268
    detector.dataEnd();
269
270
    final String charset = detector.getDetectedCharset();
271
    final Charset charEncoding = charset == null
272
      ? Charset.defaultCharset()
273
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
274
275
    detector.reset();
276
277
    return charEncoding;
278
  }
279
280
  /**
281
   * Converts the given string to an array of bytes using the encoding that was
282
   * originally detected (if any) and associated with this file.
283
   *
284
   * @param text The text to convert into the original file encoding.
285
   *
286
   * @return A series of bytes ready for writing to a file.
287
   */
288
  private byte[] asBytes( final String text ) {
289
    return text.getBytes( getEncoding() );
290
  }
291
292
  /**
293
   * Converts the given bytes into a Java String. This will call setEncoding
294
   * with the encoding detected by the CharsetDetector.
295
   *
296
   * @param text The text of unknown character encoding.
297
   *
298
   * @return The text, in its auto-detected encoding, as a String.
299
   */
300
  private String asString( final byte[] text ) {
301
    setEncoding( detectEncoding( text ) );
302
    return new String( text, getEncoding() );
303
  }
304
  
305
  public Path getPath() {
306
    return this.path;
307
  }
308
309
  void setPath( final Path path ) {
310
    this.path = path;
311
  }
312
313
  public boolean isModified() {
314
    return this.modified.get();
315
  }
316
317
  ReadOnlyBooleanProperty modifiedProperty() {
318
    return this.modified.getReadOnlyProperty();
319
  }
320
321
  BooleanProperty canUndoProperty() {
322
    return this.canUndo;
323
  }
324
325
  BooleanProperty canRedoProperty() {
326
    return this.canRedo;
327
  }
328
329
  private UndoManager getUndoManager() {
330
    return getEditorPane().getUndoManager();
331
  }
332
333
  /**
334
   * Forwards the request to the editor pane.
335
   *
336
   * @param <T> The type of event listener to add.
337
   * @param <U> The type of consumer to add.
338
   * @param event The event that should trigger updates to the listener.
339
   * @param consumer The listener to receive update events.
340
   */
341
  public <T extends Event, U extends T> void addEventListener(
342
    final EventPattern<? super T, ? extends U> event,
343
    final Consumer<? super U> consumer ) {
344
    getEditorPane().addEventListener( event, consumer );
345
  }
346
347
  /**
348
   * Forwards to the editor pane's listeners for keyboard events.
349
   *
350
   * @param map The new input map to replace the existing keyboard listener.
351
   */
352
  public void addEventListener( final InputMap<InputEvent> map ) {
353
    getEditorPane().addEventListener( map );
354
  }
355
356
  /**
357
   * Forwards to the editor pane's listeners for keyboard events.
358
   *
359
   * @param map The existing input map to remove from the keyboard listeners.
360
   */
361
  public void removeEventListener( final InputMap<InputEvent> map ) {
362
    getEditorPane().removeEventListener( map );
363
  }
364
365
  /**
366
   * Forwards to the editor pane's listeners for text change events.
367
   *
368
   * @param listener The listener to notify when the text changes.
369
   */
370
  public void addTextChangeListener( final ChangeListener<String> listener ) {
371
    getEditorPane().addTextChangeListener( listener );
372
  }
373
374
  /**
375
   * Forwards to the editor pane's listeners for caret paragraph change events.
376
   *
377
   * @param listener The listener to notify when the caret changes paragraphs.
378
   */
379
  public void addCaretParagraphListener( final ChangeListener<Integer> listener ) {
380
    getEditorPane().addCaretParagraphListener( listener );
381
  }
382
383
  /**
384
   * Forwards the request to the editor pane.
385
   *
386
   * @return The text to process.
387
   */
388
  public String getEditorText() {
389
    return getEditorPane().getText();
390
  }
391
392
  /**
393
   * Returns the editor pane, or creates one if it doesn't yet exist.
394
   *
395
   * @return The editor pane, never null.
396
   */
397
  public EditorPane getEditorPane() {
398
    if( this.editorPane == null ) {
399
      this.editorPane = new MarkdownEditorPane();
400
    }
401
402
    return this.editorPane;
403
  }
404
405
  private AlertService getAlertService() {
406
    return this.alertService;
414407
  }
415408
M src/main/java/com/scrivenvar/Main.java
3030
import static com.scrivenvar.Constants.*;
3131
import com.scrivenvar.service.Options;
32
import com.scrivenvar.service.Snitch;
3233
import com.scrivenvar.service.events.AlertService;
3334
import com.scrivenvar.util.StageState;
...
4546
public final class Main extends Application {
4647
47
  private static Application app;
48
  private final Options options = Services.load( Options.class );
49
  private final Snitch snitch = Services.load( Snitch.class );
50
  private Thread snitchThread;
4851
52
  private static Application app;
4953
  private final MainWindow mainWindow = new MainWindow();
50
  private final Options options = Services.load( Options.class );
5154
52
  public static void main( String[] args ) {
55
  public static void main( final String[] args ) {
5356
    launch( args );
5457
  }
...
6770
    initStage( stage );
6871
    initAlertService();
72
    initWatchDog();
6973
7074
    stage.show();
71
  }
72
73
  private void initApplication() {
74
    app = this;
7575
  }
7676
77
  private Options getOptions() {
78
    return this.options;
77
  public static void showDocument( final String uri ) {
78
    getApplication().getHostServices().showDocument( uri );
7979
  }
8080
81
  private String getApplicationTitle() {
82
    return Messages.get( "Main.title" );
81
  private void initApplication() {
82
    app = this;
8383
  }
8484
85
  private StageState initState( Stage stage ) {
85
  private StageState initState( final Stage stage ) {
8686
    return new StageState( stage, getOptions().getState() );
8787
  }
8888
89
  private void initStage( Stage stage ) {
89
  private void initStage( final Stage stage ) {
9090
    stage.getIcons().addAll(
9191
      createImage( FILE_LOGO_16 ),
...
101101
    final AlertService service = Services.load( AlertService.class );
102102
    service.setWindow( getScene().getWindow() );
103
  }
104
105
  private void initWatchDog() {
106
    setSnitchThread( new Thread( getWatchDog() ) );
107
    getSnitchThread().start();
108
  }
109
110
  /**
111
   * Stops the snitch service, if its running.
112
   *
113
   * @throws InterruptedException Couldn't stop the snitch thread.
114
   */
115
  @Override
116
  public void stop() throws InterruptedException {
117
    getWatchDog().stop();
118
119
    final Thread thread = getSnitchThread();
120
121
    if( thread != null ) {
122
      thread.interrupt();
123
      thread.join();
124
    }
125
  }
126
127
  private Snitch getWatchDog() {
128
    return this.snitch;
129
  }
130
131
  private Thread getSnitchThread() {
132
    return this.snitchThread;
133
  }
134
135
  private void setSnitchThread( final Thread thread ) {
136
    this.snitchThread = thread;
137
  }
138
139
  private Options getOptions() {
140
    return this.options;
103141
  }
104142
...
111149
  }
112150
113
  private static Application getApplication() {
114
    return app;
151
  private String getApplicationTitle() {
152
    return Messages.get( "Main.title" );
115153
  }
116154
117
  public static void showDocument( String uri ) {
118
    getApplication().getHostServices().showDocument( uri );
155
  private static Application getApplication() {
156
    return app;
119157
  }
120158
M src/main/java/com/scrivenvar/MainWindow.java
3131
import static com.scrivenvar.Constants.PREFS_DEFINITION_SOURCE;
3232
import static com.scrivenvar.Constants.STYLESHEET_SCENE;
33
import com.scrivenvar.definition.DefinitionFactory;
34
import com.scrivenvar.definition.DefinitionPane;
35
import com.scrivenvar.definition.DefinitionSource;
36
import com.scrivenvar.definition.EmptyDefinitionSource;
37
import com.scrivenvar.editors.VariableNameInjector;
38
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
39
import com.scrivenvar.preview.HTMLPreviewPane;
40
import com.scrivenvar.processors.HTMLPreviewProcessor;
41
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
42
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
43
import com.scrivenvar.processors.MarkdownProcessor;
44
import com.scrivenvar.processors.Processor;
45
import com.scrivenvar.processors.VariableProcessor;
46
import com.scrivenvar.service.Options;
47
import com.scrivenvar.util.Action;
48
import com.scrivenvar.util.ActionUtils;
49
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
50
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
51
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
52
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
66
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
67
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
68
import java.net.MalformedURLException;
69
import java.nio.file.Path;
70
import java.util.Map;
71
import java.util.function.Function;
72
import java.util.prefs.Preferences;
73
import javafx.beans.binding.Bindings;
74
import javafx.beans.binding.BooleanBinding;
75
import javafx.beans.property.BooleanProperty;
76
import javafx.beans.property.SimpleBooleanProperty;
77
import javafx.beans.value.ObservableBooleanValue;
78
import javafx.beans.value.ObservableValue;
79
import javafx.collections.ListChangeListener.Change;
80
import javafx.collections.ObservableList;
81
import javafx.event.Event;
82
import javafx.scene.Node;
83
import javafx.scene.Scene;
84
import javafx.scene.control.Alert;
85
import javafx.scene.control.Alert.AlertType;
86
import javafx.scene.control.Menu;
87
import javafx.scene.control.MenuBar;
88
import javafx.scene.control.SplitPane;
89
import javafx.scene.control.Tab;
90
import javafx.scene.control.ToolBar;
91
import javafx.scene.control.TreeView;
92
import javafx.scene.image.Image;
93
import javafx.scene.image.ImageView;
94
import static javafx.scene.input.KeyCode.ESCAPE;
95
import javafx.scene.input.KeyEvent;
96
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
97
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
98
import javafx.scene.layout.BorderPane;
99
import javafx.scene.layout.VBox;
100
import javafx.stage.Window;
101
import javafx.stage.WindowEvent;
102
import static com.scrivenvar.Messages.get;
103
104
/**
105
 * Main window containing a tab pane in the center for file editors.
106
 *
107
 * @author Karl Tauber and White Magic Software, Ltd.
108
 */
109
public class MainWindow {
110
111
  private final Options options = Services.load( Options.class );
112
113
  private Scene scene;
114
  private MenuBar menuBar;
115
116
  private DefinitionPane definitionPane;
117
  private FileEditorTabPane fileEditorPane;
118
  private HTMLPreviewPane previewPane;
119
120
  private DefinitionSource definitionSource;
121
122
  public MainWindow() {
123
    initLayout();
124
    initOpenDefinitionListener();
125
    initTabAddedListener();
126
    initTabChangedListener();
127
    initPreferences();
128
  }
129
130
  /**
131
   * Listen for file editor tab pane to receive an open definition source event.
132
   */
133
  private void initOpenDefinitionListener() {
134
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
135
      (ObservableValue<? extends Path> definitionFile,
136
        final Path oldPath, final Path newPath) -> {
137
        openDefinition( newPath );
138
        refreshSelectedTab( getActiveFileEditor() );
139
      } );
140
  }
141
142
  /**
143
   * When tabs are added, hook the various change listeners onto the new tab so
144
   * that the preview pane refreshes as necessary.
145
   */
146
  private void initTabAddedListener() {
147
    final FileEditorTabPane editorPane = getFileEditorPane();
148
149
    // Make sure the text processor kicks off when new files are opened.
150
    final ObservableList<Tab> tabs = editorPane.getTabs();
151
152
    // Update the preview pane on tab changes.
153
    tabs.addListener(
154
      (final Change<? extends Tab> change) -> {
155
        while( change.next() ) {
156
          if( change.wasAdded() ) {
157
            // Multiple tabs can be added simultaneously.
158
            for( final Tab newTab : change.getAddedSubList() ) {
159
              final FileEditorTab tab = (FileEditorTab)newTab;
160
161
              initTextChangeListener( tab );
162
              initCaretParagraphListener( tab );
163
              initVariableNameInjector( tab );
164
            }
165
          }
166
        }
167
      }
168
    );
169
  }
170
171
  /**
172
   * Reloads the preferences from the previous load.
173
   */
174
  private void initPreferences() {
175
    getFileEditorPane().restorePreferences();
176
    restoreDefinitionSource();
177
  }
178
179
  /**
180
   * Listen for new tab selection events.
181
   */
182
  private void initTabChangedListener() {
183
    final FileEditorTabPane editorPane = getFileEditorPane();
184
185
    // Update the preview pane changing tabs.
186
    editorPane.addTabSelectionListener(
187
      (ObservableValue<? extends Tab> tabPane,
188
        final Tab oldTab, final Tab newTab) -> {
189
190
        // If there was no old tab, then this is a first time load, which
191
        // can be ignored.
192
        if( oldTab != null ) {
193
          if( newTab == null ) {
194
            closeRemainingTab();
195
          } else {
196
            // Synchronize the preview with the edited text.
197
            refreshSelectedTab( (FileEditorTab)newTab );
198
          }
199
        }
200
      }
201
    );
202
  }
203
204
  private void initTextChangeListener( final FileEditorTab tab ) {
205
    tab.addTextChangeListener(
206
      (ObservableValue<? extends String> editor,
207
        final String oldValue, final String newValue) -> {
208
        refreshSelectedTab( tab );
209
      }
210
    );
211
  }
212
213
  private void initCaretParagraphListener( final FileEditorTab tab ) {
214
    tab.addCaretParagraphListener(
215
      (ObservableValue<? extends Integer> editor,
216
        final Integer oldValue, final Integer newValue) -> {
217
        refreshSelectedTab( tab );
218
      }
219
    );
220
  }
221
222
  private void initVariableNameInjector( final FileEditorTab tab ) {
223
    VariableNameInjector vni = new VariableNameInjector( tab, getDefinitionPane() );
224
  }
225
226
  /**
227
   * Called whenever the preview pane becomes out of sync with the file editor
228
   * tab. This can be called when the text changes, the caret paragraph changes,
229
   * or the file tab changes.
230
   *
231
   * @param tab The file editor tab that has been changed in some fashion.
232
   */
233
  private void refreshSelectedTab( final FileEditorTab tab ) {
234
    final HTMLPreviewPane preview = getPreviewPane();
235
    preview.setPath( tab.getPath() );
236
237
    final Processor<String> hpp = new HTMLPreviewProcessor( preview );
238
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
239
    final Processor<String> mp = new MarkdownProcessor( mcrp );
240
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
241
    final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
242
243
    vp.processChain( tab.getEditorText() );
244
  }
245
246
  /**
247
   * Returns the variable map of interpolated definitions.
248
   *
249
   * @return A map to help dereference variables.
250
   */
251
  private Map<String, String> getResolvedMap() {
252
    return getDefinitionSource().getResolvedMap();
253
  }
254
255
  /**
256
   * Returns the root node for the hierarchical definition source.
257
   *
258
   * @return Data to display in the definition pane.
259
   */
260
  private TreeView<String> getTreeView() {
261
    try {
262
      return getDefinitionSource().asTreeView();
263
    } catch( Exception e ) {
264
      alert( e );
265
    }
266
267
    return new TreeView<>();
268
  }
269
270
  private void openDefinition( final Path path ) {
271
    openDefinition( path.toString() );
272
  }
273
274
  private void openDefinition( final String path ) {
275
    try {
276
      final DefinitionSource ds = createDefinitionSource( path );
277
      setDefinitionSource( ds );
278
      storeDefinitionSource();
279
280
      getDefinitionPane().setRoot( ds.asTreeView() );
281
    } catch( Exception e ) {
282
      alert( e );
283
    }
284
  }
285
286
  private void restoreDefinitionSource() {
287
    final Preferences preferences = getPreferences();
288
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
289
290
    if( source != null ) {
291
      openDefinition( source );
292
    }
293
  }
294
295
  private void storeDefinitionSource() {
296
    final Preferences preferences = getPreferences();
297
    final DefinitionSource ds = getDefinitionSource();
298
299
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
300
  }
301
302
  /**
303
   * Called when the last open tab is closed. This clears out the preview pane
304
   * and the definition pane.
305
   */
306
  private void closeRemainingTab() {
307
    getPreviewPane().clear();
308
    getDefinitionPane().clear();
309
  }
310
311
  /**
312
   * Called when an exception occurs that warrants the user's attention.
313
   *
314
   * @param e The exception with a message that the user should know about.
315
   */
316
  private void alert( final Exception e ) {
317
    // TODO: Raise a notice.
318
  }
319
320
  //---- File actions -------------------------------------------------------
321
  private void fileNew() {
322
    getFileEditorPane().newEditor();
323
  }
324
325
  private void fileOpen() {
326
    getFileEditorPane().openFileDialog();
327
  }
328
329
  private void fileClose() {
330
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
331
  }
332
333
  private void fileCloseAll() {
334
    getFileEditorPane().closeAllEditors();
335
  }
336
337
  private void fileSave() {
338
    getFileEditorPane().saveEditor( getActiveFileEditor() );
339
  }
340
341
  private void fileSaveAll() {
342
    getFileEditorPane().saveAllEditors();
343
  }
344
345
  private void fileExit() {
346
    final Window window = getWindow();
347
    Event.fireEvent( window,
348
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
349
  }
350
351
  //---- Help actions -------------------------------------------------------
352
  private void helpAbout() {
353
    Alert alert = new Alert( AlertType.INFORMATION );
354
    alert.setTitle( get( "Dialog.about.title" ) );
355
    alert.setHeaderText( get( "Dialog.about.header" ) );
356
    alert.setContentText( get( "Dialog.about.content" ) );
357
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
358
    alert.initOwner( getWindow() );
359
360
    alert.showAndWait();
361
  }
362
363
  //---- Convenience accessors ----------------------------------------------
364
  private float getFloat( final String key, final float defaultValue ) {
365
    return getPreferences().getFloat( key, defaultValue );
366
  }
367
368
  private Preferences getPreferences() {
369
    return getOptions().getState();
370
  }
371
372
  private Window getWindow() {
373
    return getScene().getWindow();
374
  }
375
376
  private MarkdownEditorPane getActiveEditor() {
377
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
378
  }
379
380
  private FileEditorTab getActiveFileEditor() {
381
    return getFileEditorPane().getActiveFileEditor();
382
  }
383
384
  //---- Member accessors ---------------------------------------------------
385
  public Scene getScene() {
386
    return this.scene;
387
  }
388
389
  private void setScene( Scene scene ) {
390
    this.scene = scene;
391
  }
392
393
  private FileEditorTabPane getFileEditorPane() {
394
    if( this.fileEditorPane == null ) {
395
      this.fileEditorPane = createFileEditorPane();
396
    }
397
398
    return this.fileEditorPane;
399
  }
400
401
  private synchronized HTMLPreviewPane getPreviewPane() {
402
    if( this.previewPane == null ) {
403
      this.previewPane = createPreviewPane();
404
    }
405
406
    return this.previewPane;
407
  }
408
409
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
410
    this.definitionSource = definitionSource;
411
  }
412
413
  private synchronized DefinitionSource getDefinitionSource() {
414
    if( this.definitionSource == null ) {
415
      this.definitionSource = new EmptyDefinitionSource();
416
    }
417
418
    return this.definitionSource;
419
  }
420
421
  private DefinitionPane getDefinitionPane() {
422
    if( this.definitionPane == null ) {
423
      this.definitionPane = createDefinitionPane();
424
    }
425
426
    return this.definitionPane;
427
  }
428
429
  private Options getOptions() {
430
    return this.options;
431
  }
432
433
  public MenuBar getMenuBar() {
434
    return this.menuBar;
435
  }
436
437
  public void setMenuBar( MenuBar menuBar ) {
438
    this.menuBar = menuBar;
439
  }
440
441
  //---- Member creators ----------------------------------------------------
442
  private DefinitionSource createDefinitionSource( final String path )
443
    throws MalformedURLException {
444
    return createDefinitionFactory().createDefinitionSource( path );
445
  }
446
447
  /**
448
   * Create an editor pane to hold file editor tabs.
449
   *
450
   * @return A new instance, never null.
451
   */
452
  private FileEditorTabPane createFileEditorPane() {
453
    return new FileEditorTabPane();
454
  }
455
456
  private HTMLPreviewPane createPreviewPane() {
457
    return new HTMLPreviewPane();
458
  }
459
460
  private DefinitionPane createDefinitionPane() {
461
    return new DefinitionPane( getTreeView() );
462
  }
463
464
  private DefinitionFactory createDefinitionFactory() {
465
    return new DefinitionFactory();
466
  }
467
468
  private Node createMenuBar() {
469
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
470
471
    // File actions
472
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
473
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
474
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
475
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
476
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
477
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
478
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
479
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
480
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
481
482
    // Edit actions
483
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
484
      e -> getActiveEditor().undo(),
485
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
486
    Action editRedoAction = new Action( 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( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
492
      e -> getActiveEditor().surroundSelection( "**", "**" ),
493
      activeFileEditorIsNull );
494
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
495
      e -> getActiveEditor().surroundSelection( "*", "*" ),
496
      activeFileEditorIsNull );
497
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
498
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
499
      activeFileEditorIsNull );
500
    Action insertBlockquoteAction = new Action( 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( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
504
      e -> getActiveEditor().surroundSelection( "`", "`" ),
505
      activeFileEditorIsNull );
506
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
507
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
508
      activeFileEditorIsNull );
509
510
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
511
      e -> getActiveEditor().insertLink(),
512
      activeFileEditorIsNull );
513
    Action insertImageAction = new Action( 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 );
33
import static com.scrivenvar.Messages.get;
34
import com.scrivenvar.definition.DefinitionFactory;
35
import com.scrivenvar.definition.DefinitionPane;
36
import com.scrivenvar.definition.DefinitionSource;
37
import com.scrivenvar.definition.EmptyDefinitionSource;
38
import com.scrivenvar.editors.EditorPane;
39
import com.scrivenvar.editors.VariableNameInjector;
40
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
41
import com.scrivenvar.preview.HTMLPreviewPane;
42
import com.scrivenvar.processors.CaretReplacementProcessor;
43
import com.scrivenvar.processors.HTMLPreviewProcessor;
44
import com.scrivenvar.processors.MarkdownProcessor;
45
import com.scrivenvar.processors.Processor;
46
import com.scrivenvar.processors.VariableProcessor;
47
import com.scrivenvar.processors.XMLCaretInsertionProcessor;
48
import com.scrivenvar.processors.XMLProcessor;
49
import com.scrivenvar.service.Options;
50
import com.scrivenvar.util.Action;
51
import com.scrivenvar.util.ActionUtils;
52
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
53
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
54
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
66
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
67
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
68
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
69
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
70
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
71
import java.net.MalformedURLException;
72
import java.nio.file.Path;
73
import java.util.Map;
74
import java.util.function.Function;
75
import java.util.prefs.Preferences;
76
import javafx.beans.binding.Bindings;
77
import javafx.beans.binding.BooleanBinding;
78
import javafx.beans.property.BooleanProperty;
79
import javafx.beans.property.SimpleBooleanProperty;
80
import javafx.beans.value.ObservableBooleanValue;
81
import javafx.beans.value.ObservableValue;
82
import javafx.collections.ListChangeListener.Change;
83
import javafx.collections.ObservableList;
84
import static javafx.event.Event.fireEvent;
85
import javafx.scene.Node;
86
import javafx.scene.Scene;
87
import javafx.scene.control.Alert;
88
import javafx.scene.control.Alert.AlertType;
89
import javafx.scene.control.Menu;
90
import javafx.scene.control.MenuBar;
91
import javafx.scene.control.SplitPane;
92
import javafx.scene.control.Tab;
93
import javafx.scene.control.ToolBar;
94
import javafx.scene.control.TreeView;
95
import javafx.scene.image.Image;
96
import javafx.scene.image.ImageView;
97
import static javafx.scene.input.KeyCode.ESCAPE;
98
import javafx.scene.input.KeyEvent;
99
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
100
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
101
import javafx.scene.layout.BorderPane;
102
import javafx.scene.layout.VBox;
103
import javafx.stage.Window;
104
import javafx.stage.WindowEvent;
105
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
106
107
/**
108
 * Main window containing a tab pane in the center for file editors.
109
 *
110
 * @author Karl Tauber and White Magic Software, Ltd.
111
 */
112
public class MainWindow {
113
114
  private final Options options = Services.load( Options.class );
115
116
  private Scene scene;
117
  private MenuBar menuBar;
118
119
  private DefinitionPane definitionPane;
120
  private FileEditorTabPane fileEditorPane;
121
  private HTMLPreviewPane previewPane;
122
123
  private DefinitionSource definitionSource;
124
125
  public MainWindow() {
126
    initLayout();
127
    initOpenDefinitionListener();
128
    initTabAddedListener();
129
    initTabChangedListener();
130
    initPreferences();
131
  }
132
133
  /**
134
   * Listen for file editor tab pane to receive an open definition source event.
135
   */
136
  private void initOpenDefinitionListener() {
137
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
138
      (ObservableValue<? extends Path> definitionFile,
139
        final Path oldPath, final Path newPath) -> {
140
        openDefinition( newPath );
141
        refreshSelectedTab( getActiveFileEditor() );
142
      } );
143
  }
144
145
  /**
146
   * When tabs are added, hook the various change listeners onto the new tab so
147
   * that the preview pane refreshes as necessary.
148
   */
149
  private void initTabAddedListener() {
150
    final FileEditorTabPane editorPane = getFileEditorPane();
151
152
    // Make sure the text processor kicks off when new files are opened.
153
    final ObservableList<Tab> tabs = editorPane.getTabs();
154
155
    // Update the preview pane on tab changes.
156
    tabs.addListener(
157
      (final Change<? extends Tab> change) -> {
158
        while( change.next() ) {
159
          if( change.wasAdded() ) {
160
            // Multiple tabs can be added simultaneously.
161
            for( final Tab newTab : change.getAddedSubList() ) {
162
              final FileEditorTab tab = (FileEditorTab)newTab;
163
164
              initTextChangeListener( tab );
165
              initCaretParagraphListener( tab );
166
              initVariableNameInjector( tab );
167
            }
168
          }
169
        }
170
      }
171
    );
172
  }
173
174
  /**
175
   * Reloads the preferences from the previous load.
176
   */
177
  private void initPreferences() {
178
    getFileEditorPane().restorePreferences();
179
    restoreDefinitionSource();
180
  }
181
182
  /**
183
   * Listen for new tab selection events.
184
   */
185
  private void initTabChangedListener() {
186
    final FileEditorTabPane editorPane = getFileEditorPane();
187
188
    // Update the preview pane changing tabs.
189
    editorPane.addTabSelectionListener(
190
      (ObservableValue<? extends Tab> tabPane,
191
        final Tab oldTab, final Tab newTab) -> {
192
193
        // If there was no old tab, then this is a first time load, which
194
        // can be ignored.
195
        if( oldTab != null ) {
196
          if( newTab == null ) {
197
            closeRemainingTab();
198
          } else {
199
            // Update the preview with the edited text.
200
            refreshSelectedTab( (FileEditorTab)newTab );
201
          }
202
        }
203
      }
204
    );
205
  }
206
207
  private void initTextChangeListener( final FileEditorTab tab ) {
208
    tab.addTextChangeListener(
209
      (ObservableValue<? extends String> editor,
210
        final String oldValue, final String newValue) -> {
211
        refreshSelectedTab( tab );
212
      }
213
    );
214
  }
215
216
  private void initCaretParagraphListener( final FileEditorTab tab ) {
217
    tab.addCaretParagraphListener(
218
      (ObservableValue<? extends Integer> editor,
219
        final Integer oldValue, final Integer newValue) -> {
220
        refreshSelectedTab( tab );
221
      }
222
    );
223
  }
224
225
  private void initVariableNameInjector( final FileEditorTab tab ) {
226
    VariableNameInjector.listen( tab, getDefinitionPane() );
227
  }
228
  
229
  /**
230
   * Called whenever the preview pane becomes out of sync with the file editor
231
   * tab. This can be called when the text changes, the caret paragraph changes,
232
   * or the file tab changes.
233
   *
234
   * @param tab The file editor tab that has been changed in some fashion.
235
   */
236
  private void refreshSelectedTab( final FileEditorTab tab ) {
237
    final Path path = tab.getPath();
238
239
    final HTMLPreviewPane preview = getPreviewPane();
240
    preview.setPath( tab.getPath() );
241
242
    final Processor<String> hpp = new HTMLPreviewProcessor( preview );
243
    final Processor<String> mcrp = new CaretReplacementProcessor( hpp );
244
    final Processor<String> mp = new MarkdownProcessor( mcrp );
245
//    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
246
    final Processor<String> xmlp = new XMLProcessor( mp, tab.getPath() );
247
    final Processor<String> xcip = new XMLCaretInsertionProcessor( xmlp, tab.getCaretPosition() );
248
    final Processor<String> vp = new VariableProcessor( xcip, getResolvedMap() );
249
250
    vp.processChain( tab.getEditorText() );
251
  }
252
253
  /**
254
   * Returns the variable map of interpolated definitions.
255
   *
256
   * @return A map to help dereference variables.
257
   */
258
  private Map<String, String> getResolvedMap() {
259
    return getDefinitionSource().getResolvedMap();
260
  }
261
262
  /**
263
   * Returns the root node for the hierarchical definition source.
264
   *
265
   * @return Data to display in the definition pane.
266
   */
267
  private TreeView<String> getTreeView() {
268
    try {
269
      return getDefinitionSource().asTreeView();
270
    } catch( Exception e ) {
271
      alert( e );
272
    }
273
274
    return new TreeView<>();
275
  }
276
277
  private void openDefinition( final Path path ) {
278
    openDefinition( path.toString() );
279
  }
280
281
  private void openDefinition( final String path ) {
282
    try {
283
      final DefinitionSource ds = createDefinitionSource( path );
284
      setDefinitionSource( ds );
285
      storeDefinitionSource();
286
287
      getDefinitionPane().setRoot( ds.asTreeView() );
288
    } catch( Exception e ) {
289
      alert( e );
290
    }
291
  }
292
293
  private void restoreDefinitionSource() {
294
    final Preferences preferences = getPreferences();
295
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
296
297
    if( source != null ) {
298
      openDefinition( source );
299
    }
300
  }
301
302
  private void storeDefinitionSource() {
303
    final Preferences preferences = getPreferences();
304
    final DefinitionSource ds = getDefinitionSource();
305
306
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
307
  }
308
309
  /**
310
   * Called when the last open tab is closed. This clears out the preview pane
311
   * and the definition pane.
312
   */
313
  private void closeRemainingTab() {
314
    getPreviewPane().clear();
315
    getDefinitionPane().clear();
316
  }
317
318
  /**
319
   * Called when an exception occurs that warrants the user's attention.
320
   *
321
   * @param e The exception with a message that the user should know about.
322
   */
323
  private void alert( final Exception e ) {
324
    // TODO: Raise a notice.
325
  }
326
327
  //---- File actions -------------------------------------------------------
328
  private void fileNew() {
329
    getFileEditorPane().newEditor();
330
  }
331
332
  private void fileOpen() {
333
    getFileEditorPane().openFileDialog();
334
  }
335
336
  private void fileClose() {
337
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
338
  }
339
340
  private void fileCloseAll() {
341
    getFileEditorPane().closeAllEditors();
342
  }
343
344
  private void fileSave() {
345
    getFileEditorPane().saveEditor( getActiveFileEditor() );
346
  }
347
348
  private void fileSaveAll() {
349
    getFileEditorPane().saveAllEditors();
350
  }
351
352
  private void fileExit() {
353
    final Window window = getWindow();
354
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
355
  }
356
357
  //---- Help actions -------------------------------------------------------
358
  private void helpAbout() {
359
    Alert alert = new Alert( AlertType.INFORMATION );
360
    alert.setTitle( get( "Dialog.about.title" ) );
361
    alert.setHeaderText( get( "Dialog.about.header" ) );
362
    alert.setContentText( get( "Dialog.about.content" ) );
363
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
364
    alert.initOwner( getWindow() );
365
366
    alert.showAndWait();
367
  }
368
369
  //---- Convenience accessors ----------------------------------------------
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 Window getWindow() {
379
    return getScene().getWindow();
380
  }
381
382
  private MarkdownEditorPane getActiveEditor() {
383
    final EditorPane pane = getActiveFileEditor().getEditorPane();
384
385
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
386
  }
387
388
  private FileEditorTab getActiveFileEditor() {
389
    return getFileEditorPane().getActiveFileEditor();
390
  }
391
392
  //---- Member accessors ---------------------------------------------------
393
  public Scene getScene() {
394
    return this.scene;
395
  }
396
397
  private void setScene( Scene scene ) {
398
    this.scene = scene;
399
  }
400
401
  private FileEditorTabPane getFileEditorPane() {
402
    if( this.fileEditorPane == null ) {
403
      this.fileEditorPane = createFileEditorPane();
404
    }
405
406
    return this.fileEditorPane;
407
  }
408
409
  private HTMLPreviewPane getPreviewPane() {
410
    if( this.previewPane == null ) {
411
      this.previewPane = createPreviewPane();
412
    }
413
414
    return this.previewPane;
415
  }
416
417
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
418
    this.definitionSource = definitionSource;
419
  }
420
421
  private DefinitionSource getDefinitionSource() {
422
    if( this.definitionSource == null ) {
423
      this.definitionSource = new EmptyDefinitionSource();
424
    }
425
426
    return this.definitionSource;
427
  }
428
429
  private DefinitionPane getDefinitionPane() {
430
    if( this.definitionPane == null ) {
431
      this.definitionPane = createDefinitionPane();
432
    }
433
434
    return this.definitionPane;
435
  }
436
437
  private Options getOptions() {
438
    return this.options;
439
  }
440
441
  public MenuBar getMenuBar() {
442
    return this.menuBar;
443
  }
444
445
  public void setMenuBar( MenuBar menuBar ) {
446
    this.menuBar = menuBar;
447
  }
448
449
  //---- Member creators ----------------------------------------------------
450
  private DefinitionSource createDefinitionSource( final String path )
451
    throws MalformedURLException {
452
    return createDefinitionFactory().createDefinitionSource( path );
453
  }
454
455
  /**
456
   * Create an editor pane to hold file editor tabs.
457
   *
458
   * @return A new instance, never null.
459
   */
460
  private FileEditorTabPane createFileEditorPane() {
461
    return new FileEditorTabPane();
462
  }
463
464
  private HTMLPreviewPane createPreviewPane() {
465
    return new HTMLPreviewPane();
466
  }
467
468
  private DefinitionPane createDefinitionPane() {
469
    return new DefinitionPane( getTreeView() );
470
  }
471
472
  private DefinitionFactory createDefinitionFactory() {
473
    return new DefinitionFactory();
474
  }
475
476
  private Node createMenuBar() {
477
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
478
479
    // File actions
480
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
481
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
482
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
483
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
484
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
485
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
486
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
487
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
488
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
489
490
    // Edit actions
491
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
492
      e -> getActiveEditor().undo(),
493
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
494
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
495
      e -> getActiveEditor().redo(),
496
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
497
498
    // Insert actions
499
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
500
      e -> getActiveEditor().surroundSelection( "**", "**" ),
501
      activeFileEditorIsNull );
502
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
503
      e -> getActiveEditor().surroundSelection( "*", "*" ),
504
      activeFileEditorIsNull );
505
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
506
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
507
      activeFileEditorIsNull );
508
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
509
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
510
      activeFileEditorIsNull );
511
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
512
      e -> getActiveEditor().surroundSelection( "`", "`" ),
513
      activeFileEditorIsNull );
514
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
515
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
516
      activeFileEditorIsNull );
517
518
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
519
      e -> getActiveEditor().insertLink(),
520
      activeFileEditorIsNull );
521
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
522
      e -> getActiveEditor().insertImage(),
523
      activeFileEditorIsNull );
524
525
    final Action[] headers = new Action[ 6 ];
526
527
    // Insert header actions (H1 ... H6)
528
    for( int i = 1; i <= 6; i++ ) {
529
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
530
      final String markup = String.format( "%n%n%s ", hashes );
523531
      final String text = get( "Main.menu.insert.header_" + i );
524532
      final String accelerator = "Shortcut+" + i;
M src/main/java/com/scrivenvar/Services.java
2828
package com.scrivenvar;
2929
30
import java.util.HashMap;
31
import java.util.Map;
3032
import java.util.ServiceLoader;
3133
3234
/**
33
 * Responsible for loading services.
35
 * Responsible for loading services. The services are treated as singleton
36
 * instances.
3437
 *
3538
 * @author White Magic Software, Ltd.
3639
 */
3740
public class Services {
41
42
  private static final Map<Class, Object> SINGLETONS = new HashMap<>( 8 );
3843
3944
  /**
40
   * Loads a service based on its interface definition.
45
   * Loads a service based on its interface definition. This will return an
46
   * existing instance if the class has already been instantiated.
4147
   *
4248
   * @param <T> The service to load.
4349
   * @param api The interface definition for the service.
4450
   *
4551
   * @return A class that implements the interface.
4652
   */
47
  public static <T> T load( Class<T> api ) {
53
  public static <T> T load( final Class<T> api ) {
54
    @SuppressWarnings( "unchecked" )
55
    final T o = (T)get( api );
56
57
    return o == null ? newInstance( api ) : o;
58
  }
59
60
  private static <T> T newInstance( final Class<T> api ) {
4861
    final ServiceLoader<T> services = ServiceLoader.load( api );
62
4963
    T result = null;
5064
51
    for( T service : services ) {
65
    for( final T service : services ) {
5266
      result = service;
5367
...
6074
      throw new RuntimeException( "No implementation for: " + api );
6175
    }
76
77
    // Re-use the same instance the next time the class is loaded.
78
    put( api, result );
6279
6380
    return result;
81
  }
82
83
  private static void put( Class key, Object value ) {
84
    SINGLETONS.put( key, value );
85
  }
86
87
  private static Object get( Class api ) {
88
    return SINGLETONS.get( api );
6489
  }
6590
}
M src/main/java/com/scrivenvar/controls/WebHyperlink.java
2525
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
2626
 */
27
2827
package com.scrivenvar.controls;
2928
29
import com.scrivenvar.Main;
3030
import javafx.beans.property.SimpleStringProperty;
3131
import javafx.beans.property.StringProperty;
3232
import javafx.scene.control.Hyperlink;
33
import com.scrivenvar.Main;
3433
3534
/**
3635
 * Opens a web site in the default web browser.
3736
 *
3837
 * @author Karl Tauber
3938
 */
40
public class WebHyperlink
41
	extends Hyperlink
42
{
43
	public WebHyperlink() {
44
		setStyle("-fx-padding: 0; -fx-border-width: 0");
45
	}
39
public class WebHyperlink extends Hyperlink {
4640
47
	@Override
48
	public void fire() {
49
		Main.showDocument(getUri());
50
	}
41
  // 'uri' property
42
  private final StringProperty uri = new SimpleStringProperty();
5143
52
	// 'uri' property
53
	private final StringProperty uri = new SimpleStringProperty();
54
	public String getUri() { return uri.get(); }
55
	public void setUri(String uri) { this.uri.set(uri); }
56
	public StringProperty UriProperty() { return uri; }
44
  public WebHyperlink() {
45
    setStyle( "-fx-padding: 0; -fx-border-width: 0" );
46
  }
47
48
  @Override
49
  public void fire() {
50
    Main.showDocument( getUri() );
51
  }
52
53
  public String getUri() {
54
    return uri.get();
55
  }
56
57
  public void setUri( String uri ) {
58
    this.uri.set( uri );
59
  }
60
61
  public StringProperty uriProperty() {
62
    return uri;
63
  }
5764
}
5865
M src/main/java/com/scrivenvar/definition/DefinitionFactory.java
9797
9898
    final String protocol = getProtocol( path );
99
    DefinitionSource result = new EmptyDefinitionSource();
99
    DefinitionSource result = null;
100100
101101
    switch( protocol ) {
...
122122
  private DefinitionSource createFileDefinitionSource(
123123
    final String filetype, final Path path ) {
124
    DefinitionSource result = new EmptyDefinitionSource();
124
    
125
    DefinitionSource result = null;
125126
126127
    switch( filetype ) {
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
2929
3030
import com.scrivenvar.AbstractPane;
31
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
31
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR;
3232
import com.scrivenvar.predicates.strings.ContainsPredicate;
3333
import com.scrivenvar.predicates.strings.StartsPredicate;
...
160160
    TreeItem<String> pItem = cItem;
161161
162
    int index = path.indexOf( SEPARATOR );
162
    int index = path.indexOf( SEPARATOR_CHAR );
163163
164164
    while( index >= 0 ) {
165165
      final String node = path.substring( 0, index );
166166
      path = path.substring( index + 1 );
167167
168168
      if( (cItem = findStartsNode( cItem, node )) == null ) {
169169
        break;
170170
      }
171171
172
      index = path.indexOf( SEPARATOR );
172
      index = path.indexOf( SEPARATOR_CHAR );
173173
      pItem = cItem;
174174
    }
...
323323
   */
324324
  private VariableTreeItem<String> getTreeRoot() {
325
    return (VariableTreeItem<String>)getTreeView().getRoot();
325
    final TreeItem<String> root = getTreeView().getRoot();
326
327
    return root instanceof VariableTreeItem ? (VariableTreeItem<String>)root : null;
326328
  }
327329
M src/main/java/com/scrivenvar/definition/yaml/YamlFileDefinitionSource.java
101101
    this.yamlParser = yamlParser;
102102
  }
103
104
  private InputStream asStream( final String resource ) {
105
    return getClass().getResourceAsStream( resource );
106
  }
107103
}
108104
M src/main/java/com/scrivenvar/definition/yaml/YamlParser.java
7979
 * @author White Magic Software, Ltd.
8080
 */
81
public class YamlParser {
82
83
  /**
84
   * Separates YAML variable nodes (e.g., the dots in
85
   * <code>$root.node.var$</code>).
86
   */
87
  public static final String SEPARATOR = ".";
88
89
  private final static int GROUP_DELIMITED = 1;
90
  private final static int GROUP_REFERENCE = 2;
91
92
  private final static VariableDecorator VARIABLE_DECORATOR
93
    = new YamlVariableDecorator();
94
95
  /**
96
   * Compiled version of DEFAULT_REGEX.
97
   */
98
  private final static Pattern REGEX_PATTERN
99
    = Pattern.compile( YamlVariableDecorator.REGEX );
100
101
  /**
102
   * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values.
103
   */
104
  private final static char SEPARATOR_YAML = '/';
105
106
  /**
107
   * Start of the Universe (the YAML document node that contains all others).
108
   */
109
  private ObjectNode documentRoot;
110
111
  /**
112
   * Map of references to dereferenced field values.
113
   */
114
  private Map<String, String> references;
115
116
  public YamlParser() {
117
  }
118
119
  /**
120
   * Returns the given string with all the delimited references swapped with
121
   * their recursively resolved values.
122
   *
123
   * @param text The text to parse with zero or more delimited references to
124
   * replace.
125
   *
126
   * @return The substituted value.
127
   */
128
  public String substitute( String text ) {
129
    final Matcher matcher = patternMatch( text );
130
    final Map<String, String> map = getReferences();
131
132
    while( matcher.find() ) {
133
      final String key = matcher.group( GROUP_DELIMITED );
134
      final String value = map.get( key );
135
136
      if( value == null ) {
137
        missing( text );
138
      } else {
139
        text = text.replace( key, value );
140
      }
141
    }
142
143
    return text;
144
  }
145
146
  /**
147
   * Returns all the strings with their values resolved in a flat hierarchy.
148
   * This copies all the keys and resolved values into a new map.
149
   *
150
   * @return The new map created with all values having been resolved,
151
   * recursively.
152
   */
153
  public Map<String, String> createResolvedMap() {
154
    final Map<String, String> map = new HashMap<>( 1024 );
155
156
    resolve( getDocumentRoot(), "", map );
157
158
    return map;
159
  }
160
161
  /**
162
   * Iterate over a given root node (at any level of the tree) and adapt each
163
   * leaf node.
164
   *
165
   * @param rootNode A JSON node (YAML node) to adapt.
166
   */
167
  private void resolve(
168
    final JsonNode rootNode, final String path, final Map<String, String> map ) {
169
170
    if( rootNode != null ) {
171
      rootNode.fields().forEachRemaining(
172
        (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map )
173
      );
174
    }
175
  }
176
177
  /**
178
   * Recursively adapt each rootNode to a corresponding rootItem.
179
   *
180
   * @param rootNode The node to adapt.
181
   */
182
  private void resolve(
183
    final Entry<String, JsonNode> rootNode,
184
    final String path,
185
    final Map<String, String> map ) {
186
187
    final JsonNode leafNode = rootNode.getValue();
188
    final String key = rootNode.getKey();
189
190
    if( leafNode.isValueNode() ) {
191
      final String value = rootNode.getValue().asText();
192
193
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
194
    }
195
196
    if( leafNode.isObject() ) {
197
      resolve( leafNode, path + key + SEPARATOR, map );
198
    }
199
  }
200
201
  /**
202
   * Reads the first document from the given stream of YAML data and returns a
203
   * corresponding object that represents the YAML hierarchy. The calling class
204
   * is responsible for closing the stream. Calling classes should use
205
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
206
   *
207
   * @param in The input stream containing YAML content.
208
   *
209
   * @return An object hierarchy to represent the content.
210
   *
211
   * @throws IOException Could not read the stream.
212
   */
213
  public JsonNode process( final InputStream in ) throws IOException {
214
215
    final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in );
216
    setDocumentRoot( root );
217
    process( root );
218
    return getDocumentRoot();
219
  }
220
221
  /**
222
   * Iterate over a given root node (at any level of the tree) and process each
223
   * leaf node.
224
   *
225
   * @param root A node to process.
226
   */
227
  private void process( final JsonNode root ) {
228
    root.fields().forEachRemaining( this::process );
229
  }
230
231
  /**
232
   * Process the given field, which is a named node. This is where the
233
   * application does the up-front work of mapping references to their fully
234
   * recursively dereferenced values.
235
   *
236
   * @param field The named node.
237
   */
238
  private void process( final Entry<String, JsonNode> field ) {
239
    final JsonNode node = field.getValue();
240
241
    if( node.isObject() ) {
242
      process( node );
243
    } else {
244
      final JsonNode fieldValue = field.getValue();
245
246
      // Only basic data types can be parsed into variable values. For
247
      // node structures, YAML has a built-in mechanism.
248
      if( fieldValue.isValueNode() ) {
249
        try {
250
          resolve( fieldValue.asText() );
251
        } catch( StackOverflowError e ) {
252
          throw new IllegalArgumentException(
253
            "Unresolvable: " + node.textValue() + " = " + fieldValue );
254
        }
255
      }
256
    }
257
  }
258
259
  /**
260
   * Inserts the delimited references and field values into the cache. This will
261
   * overwrite existing references.
262
   *
263
   * @param fieldValue YAML field containing zero or more delimited references.
264
   * If it contains a delimited reference, the parameter is modified with the
265
   * dereferenced value before it is returned.
266
   *
267
   * @return fieldValue without delimited references.
268
   */
269
  private String resolve( String fieldValue ) {
270
    final Matcher matcher = patternMatch( fieldValue );
271
272
    while( matcher.find() ) {
273
      final String delimited = matcher.group( GROUP_DELIMITED );
274
      final String reference = matcher.group( GROUP_REFERENCE );
275
      final String dereference = resolve( lookup( reference ) );
276
277
      fieldValue = fieldValue.replace( delimited, dereference );
278
279
      // This will perform some superfluous calls by overwriting existing
280
      // items in the delimited reference map.
281
      put( delimited, dereference );
282
    }
283
284
    return fieldValue;
285
  }
286
287
  /**
288
   * Inserts a key/value pair into the references map. The map retains
289
   * references and dereferenced values found in the YAML. If the reference
290
   * already exists, this will overwrite with a new value.
291
   *
292
   * @param delimited The variable name.
293
   * @param dereferenced The resolved value.
294
   */
295
  private void put( String delimited, String dereferenced ) {
296
    if( dereferenced.isEmpty() ) {
297
      missing( delimited );
298
    } else {
299
      getReferences().put( delimited, dereferenced );
300
    }
301
  }
302
303
  /**
304
   * Writes the modified YAML document to standard output.
305
   */
306
  private void writeDocument() throws IOException {
307
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
308
  }
309
310
  /**
311
   * Called when a delimited reference is dereferenced to an empty string. This
312
   * should produce a warning for the user.
313
   *
314
   * @param delimited Delimited reference with no derived value.
315
   */
316
  private void missing( final String delimited ) {
317
    throw new InvalidParameterException(
318
      MessageFormat.format( "Missing value for '{0}'.", delimited ) );
319
  }
320
321
  /**
322
   * Returns a REGEX_PATTERN matcher for the given text.
323
   *
324
   * @param text The text that contains zero or more instances of a
325
   * REGEX_PATTERN that can be found using the regular expression.
326
   */
327
  private Matcher patternMatch( String text ) {
328
    return getPattern().matcher( text );
329
  }
330
331
  /**
332
   * Finds the YAML value for a reference.
333
   *
334
   * @param reference References a value in the YAML document.
335
   *
336
   * @return The dereferenced value.
337
   */
338
  private String lookup( final String reference ) {
339
    return getDocumentRoot().at( asPath( reference ) ).asText();
340
  }
341
342
  /**
343
   * Converts a reference (not delimited) to a path that can be used to find a
344
   * value that should exist inside the YAML document.
345
   *
346
   * @param reference The reference to convert to a YAML document path.
347
   *
348
   * @return The reference with a leading slash and its separator characters
349
   * converted to slashes.
350
   */
351
  private String asPath( final String reference ) {
352
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML );
353
  }
354
355
  /**
356
   * Sets the parent node for the entire YAML document tree.
357
   *
358
   * @param documentRoot The parent node.
359
   */
360
  private void setDocumentRoot( ObjectNode documentRoot ) {
361
    this.documentRoot = documentRoot;
362
  }
363
364
  /**
365
   * Returns the parent node for the entire YAML document tree.
366
   *
367
   * @return The parent node.
368
   */
369
  private ObjectNode getDocumentRoot() {
370
    return this.documentRoot;
371
  }
372
373
  /**
374
   * Returns the compiled regular expression REGEX_PATTERN used to match
375
   * delimited references.
376
   *
377
   * @return A compiled regex for use with the Matcher.
378
   */
379
  private Pattern getPattern() {
380
    return REGEX_PATTERN;
381
  }
382
383
  /**
384
   * Returns the list of references mapped to dereferenced values.
385
   *
386
   * @return
387
   */
388
  private Map<String, String> getReferences() {
389
    if( this.references == null ) {
390
      this.references = createReferences();
391
    }
392
393
    return this.references;
394
  }
395
396
  /**
397
   * Subclasses can override this method to insert their own map.
398
   *
399
   * @return An empty HashMap, never null.
400
   */
401
  protected Map<String, String> createReferences() {
402
    return new HashMap<>();
403
  }
404
405
  private class ResolverYAMLFactory extends YAMLFactory {
81
public class YamlParser  {
82
83
  /**
84
   * Separates YAML variable nodes (e.g., the dots in
85
   * <code>$root.node.var$</code>).
86
   */
87
  public static final String SEPARATOR = ".";
88
  public static final char SEPARATOR_CHAR = SEPARATOR.charAt( 0 );
89
90
  private final static int GROUP_DELIMITED = 1;
91
  private final static int GROUP_REFERENCE = 2;
92
93
  private final static VariableDecorator VARIABLE_DECORATOR
94
    = new YamlVariableDecorator();
95
96
  /**
97
   * Compiled version of DEFAULT_REGEX.
98
   */
99
  private final static Pattern REGEX_PATTERN
100
    = Pattern.compile( YamlVariableDecorator.REGEX );
101
102
  /**
103
   * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values.
104
   */
105
  private final static char SEPARATOR_YAML = '/';
106
107
  /**
108
   * Start of the Universe (the YAML document node that contains all others).
109
   */
110
  private ObjectNode documentRoot;
111
112
  /**
113
   * Map of references to dereferenced field values.
114
   */
115
  private Map<String, String> references;
116
117
  public YamlParser() {
118
  }
119
120
  /**
121
   * Returns the given string with all the delimited references swapped with
122
   * their recursively resolved values.
123
   *
124
   * @param text The text to parse with zero or more delimited references to
125
   * replace.
126
   *
127
   * @return The substituted value.
128
   */
129
  public String substitute( String text ) {
130
    final Matcher matcher = patternMatch( text );
131
    final Map<String, String> map = getReferences();
132
133
    while( matcher.find() ) {
134
      final String key = matcher.group( GROUP_DELIMITED );
135
      final String value = map.get( key );
136
137
      if( value == null ) {
138
        missing( text );
139
      } else {
140
        text = text.replace( key, value );
141
      }
142
    }
143
144
    return text;
145
  }
146
147
  /**
148
   * Returns all the strings with their values resolved in a flat hierarchy.
149
   * This copies all the keys and resolved values into a new map.
150
   *
151
   * @return The new map created with all values having been resolved,
152
   * recursively.
153
   */
154
  public Map<String, String> createResolvedMap() {
155
    final Map<String, String> map = new HashMap<>( 1024 );
156
157
    resolve( getDocumentRoot(), "", map );
158
159
    return map;
160
  }
161
162
  /**
163
   * Iterate over a given root node (at any level of the tree) and adapt each
164
   * leaf node.
165
   *
166
   * @param rootNode A JSON node (YAML node) to adapt.
167
   */
168
  private void resolve(
169
    final JsonNode rootNode, final String path, final Map<String, String> map ) {
170
171
    if( rootNode != null ) {
172
      rootNode.fields().forEachRemaining(
173
        (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map )
174
      );
175
    }
176
  }
177
178
  /**
179
   * Recursively adapt each rootNode to a corresponding rootItem.
180
   *
181
   * @param rootNode The node to adapt.
182
   */
183
  private void resolve(
184
    final Entry<String, JsonNode> rootNode,
185
    final String path,
186
    final Map<String, String> map ) {
187
188
    final JsonNode leafNode = rootNode.getValue();
189
    final String key = rootNode.getKey();
190
191
    if( leafNode.isValueNode() ) {
192
      final String value = rootNode.getValue().asText();
193
194
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
195
    }
196
197
    if( leafNode.isObject() ) {
198
      resolve( leafNode, path + key + SEPARATOR, map );
199
    }
200
  }
201
202
  /**
203
   * Reads the first document from the given stream of YAML data and returns a
204
   * corresponding object that represents the YAML hierarchy. The calling class
205
   * is responsible for closing the stream. Calling classes should use
206
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
207
   *
208
   * @param in The input stream containing YAML content.
209
   *
210
   * @return An object hierarchy to represent the content.
211
   *
212
   * @throws IOException Could not read the stream.
213
   */
214
  public JsonNode process( final InputStream in ) throws IOException {
215
216
    final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in );
217
    setDocumentRoot( root );
218
    process( root );
219
    return getDocumentRoot();
220
  }
221
222
  /**
223
   * Iterate over a given root node (at any level of the tree) and process each
224
   * leaf node.
225
   *
226
   * @param root A node to process.
227
   */
228
  private void process( final JsonNode root ) {
229
    root.fields().forEachRemaining( this::process );
230
  }
231
232
  /**
233
   * Process the given field, which is a named node. This is where the
234
   * application does the up-front work of mapping references to their fully
235
   * recursively dereferenced values.
236
   *
237
   * @param field The named node.
238
   */
239
  private void process( final Entry<String, JsonNode> field ) {
240
    final JsonNode node = field.getValue();
241
242
    if( node.isObject() ) {
243
      process( node );
244
    } else {
245
      final JsonNode fieldValue = field.getValue();
246
247
      // Only basic data types can be parsed into variable values. For
248
      // node structures, YAML has a built-in mechanism.
249
      if( fieldValue.isValueNode() ) {
250
        try {
251
          resolve( fieldValue.asText() );
252
        } catch( StackOverflowError e ) {
253
          throw new IllegalArgumentException(
254
            "Unresolvable: " + node.textValue() + " = " + fieldValue );
255
        }
256
      }
257
    }
258
  }
259
260
  /**
261
   * Inserts the delimited references and field values into the cache. This will
262
   * overwrite existing references.
263
   *
264
   * @param fieldValue YAML field containing zero or more delimited references.
265
   * If it contains a delimited reference, the parameter is modified with the
266
   * dereferenced value before it is returned.
267
   *
268
   * @return fieldValue without delimited references.
269
   */
270
  private String resolve( String fieldValue ) {
271
    final Matcher matcher = patternMatch( fieldValue );
272
273
    while( matcher.find() ) {
274
      final String delimited = matcher.group( GROUP_DELIMITED );
275
      final String reference = matcher.group( GROUP_REFERENCE );
276
      final String dereference = resolve( lookup( reference ) );
277
278
      fieldValue = fieldValue.replace( delimited, dereference );
279
280
      // This will perform some superfluous calls by overwriting existing
281
      // items in the delimited reference map.
282
      put( delimited, dereference );
283
    }
284
285
    return fieldValue;
286
  }
287
288
  /**
289
   * Inserts a key/value pair into the references map. The map retains
290
   * references and dereferenced values found in the YAML. If the reference
291
   * already exists, this will overwrite with a new value.
292
   *
293
   * @param delimited The variable name.
294
   * @param dereferenced The resolved value.
295
   */
296
  private void put( String delimited, String dereferenced ) {
297
    if( dereferenced.isEmpty() ) {
298
      missing( delimited );
299
    } else {
300
      getReferences().put( delimited, dereferenced );
301
    }
302
  }
303
304
  /**
305
   * Writes the modified YAML document to standard output.
306
   */
307
  private void writeDocument() throws IOException {
308
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
309
  }
310
311
  /**
312
   * Called when a delimited reference is dereferenced to an empty string. This
313
   * should produce a warning for the user.
314
   *
315
   * @param delimited Delimited reference with no derived value.
316
   */
317
  private void missing( final String delimited ) {
318
    throw new InvalidParameterException(
319
      MessageFormat.format( "Missing value for '{0}'.", delimited ) );
320
  }
321
322
  /**
323
   * Returns a REGEX_PATTERN matcher for the given text.
324
   *
325
   * @param text The text that contains zero or more instances of a
326
   * REGEX_PATTERN that can be found using the regular expression.
327
   */
328
  private Matcher patternMatch( String text ) {
329
    return getPattern().matcher( text );
330
  }
331
332
  /**
333
   * Finds the YAML value for a reference.
334
   *
335
   * @param reference References a value in the YAML document.
336
   *
337
   * @return The dereferenced value.
338
   */
339
  private String lookup( final String reference ) {
340
    return getDocumentRoot().at( asPath( reference ) ).asText();
341
  }
342
343
  /**
344
   * Converts a reference (not delimited) to a path that can be used to find a
345
   * value that should exist inside the YAML document.
346
   *
347
   * @param reference The reference to convert to a YAML document path.
348
   *
349
   * @return The reference with a leading slash and its separator characters
350
   * converted to slashes.
351
   */
352
  private String asPath( final String reference ) {
353
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML );
354
  }
355
356
  /**
357
   * Sets the parent node for the entire YAML document tree.
358
   *
359
   * @param documentRoot The parent node.
360
   */
361
  private void setDocumentRoot( ObjectNode documentRoot ) {
362
    this.documentRoot = documentRoot;
363
  }
364
365
  /**
366
   * Returns the parent node for the entire YAML document tree.
367
   *
368
   * @return The parent node.
369
   */
370
  private ObjectNode getDocumentRoot() {
371
    return this.documentRoot;
372
  }
373
374
  /**
375
   * Returns the compiled regular expression REGEX_PATTERN used to match
376
   * delimited references.
377
   *
378
   * @return A compiled regex for use with the Matcher.
379
   */
380
  private Pattern getPattern() {
381
    return REGEX_PATTERN;
382
  }
383
384
  /**
385
   * Returns the list of references mapped to dereferenced values.
386
   *
387
   * @return
388
   */
389
  private Map<String, String> getReferences() {
390
    if( this.references == null ) {
391
      this.references = createReferences();
392
    }
393
394
    return this.references;
395
  }
396
397
  /**
398
   * Subclasses can override this method to insert their own map.
399
   *
400
   * @return An empty HashMap, never null.
401
   */
402
  protected Map<String, String> createReferences() {
403
    return new HashMap<>();
404
  }
405
406
  private final class ResolverYAMLFactory extends YAMLFactory {
407
408
    private static final long serialVersionUID = 1L;
406409
407410
    @Override
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
3535
import com.scrivenvar.definition.VariableTreeItem;
3636
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
37
import com.scrivenvar.service.Settings;
38
import static com.scrivenvar.util.Lists.getFirst;
39
import static com.scrivenvar.util.Lists.getLast;
40
import static java.lang.Character.isSpaceChar;
41
import static java.lang.Character.isWhitespace;
42
import static java.lang.Math.min;
43
import java.util.function.Consumer;
44
import javafx.collections.ObservableList;
45
import javafx.event.Event;
46
import javafx.scene.control.IndexRange;
47
import javafx.scene.control.TreeItem;
48
import javafx.scene.input.InputEvent;
49
import javafx.scene.input.KeyCode;
50
import static javafx.scene.input.KeyCode.AT;
51
import static javafx.scene.input.KeyCode.DIGIT2;
52
import static javafx.scene.input.KeyCode.ENTER;
53
import static javafx.scene.input.KeyCode.MINUS;
54
import static javafx.scene.input.KeyCode.SPACE;
55
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
56
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
57
import javafx.scene.input.KeyEvent;
58
import org.fxmisc.richtext.StyledTextArea;
59
import org.fxmisc.wellbehaved.event.EventPattern;
60
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
61
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
62
import org.fxmisc.wellbehaved.event.InputMap;
63
import static org.fxmisc.wellbehaved.event.InputMap.consume;
64
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
65
66
/**
67
 * Provides the logic for injecting variable names within the editor.
68
 *
69
 * @author White Magic Software, Ltd.
70
 */
71
public class VariableNameInjector {
72
73
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
74
75
  private static final int NO_DIFFERENCE = -1;
76
77
  private final Settings settings = Services.load( Settings.class );
78
79
  /**
80
   * Used to capture keyboard events once the user presses @.
81
   */
82
  private InputMap<InputEvent> keyboardMap;
83
84
  private FileEditorTab tab;
85
  private DefinitionPane definitionPane;
86
87
  /**
88
   * Position of the variable in the text when in variable mode (0 by default).
89
   */
90
  private int initialCaretPosition;
91
92
  public VariableNameInjector(
93
    final FileEditorTab tab,
94
    final DefinitionPane definitionPane ) {
95
    setFileEditorTab( tab );
96
    setDefinitionPane( definitionPane );
97
98
    initKeyboardEventListeners();
99
  }
100
101
  /**
102
   * Traps keys for performing various short-cut tasks, such as @-mode variable
103
   * insertion and control+space for variable autocomplete.
104
   *
105
   * @ key is pressed, a new keyboard map is inserted in place of the current
106
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
107
   *
108
   * @see createKeyboardMap()
109
   */
110
  private void initKeyboardEventListeners() {
111
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
112
113
    // @ key in Linux?
114
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
115
    // @ key in Windows.
116
    addEventListener( keyPressed( AT ), this::vMode );
117
  }
118
119
  /**
120
   * The @ symbol is a short-cut to inserting a YAML variable reference.
121
   *
122
   * @param e Superfluous information about the key that was pressed.
123
   */
124
  private void vMode( KeyEvent e ) {
125
    setInitialCaretPosition();
126
    vModeStart();
127
    vModeAutocomplete();
128
  }
129
130
  /**
131
   * Receives key presses until the user completes the variable selection. This
132
   * allows the arrow keys to be used for selecting variables.
133
   *
134
   * @param e The key that was pressed.
135
   */
136
  private void vModeKeyPressed( KeyEvent e ) {
137
    final KeyCode keyCode = e.getCode();
138
139
    switch( keyCode ) {
140
      case BACK_SPACE:
141
        // Don't decorate the variable upon exiting vMode.
142
        vModeBackspace();
143
        break;
144
145
      case ESCAPE:
146
        // Don't decorate the variable upon exiting vMode.
147
        vModeStop();
148
        break;
149
150
      case ENTER:
151
      case PERIOD:
152
      case RIGHT:
153
      case END:
154
        // Stop at a leaf node, ENTER means accept.
155
        if( vModeConditionalComplete() && keyCode == ENTER ) {
156
          vModeStop();
157
158
          // Decorate the variable upon exiting vMode.
159
          decorateVariable();
160
        }
161
        break;
162
163
      case UP:
164
        cyclePathPrev();
165
        break;
166
167
      case DOWN:
168
        cyclePathNext();
169
        break;
170
171
      default:
172
        vModeFilterKeyPressed( e );
173
        break;
174
    }
175
176
    e.consume();
177
  }
178
179
  private void vModeBackspace() {
180
    deleteSelection();
181
182
    // Break out of variable mode by back spacing to the original position.
183
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
184
      vModeAutocomplete();
185
    } else {
186
      vModeStop();
187
    }
188
  }
189
190
  /**
191
   * Updates the text with the path selected (or typed) by the user.
192
   */
193
  private void vModeAutocomplete() {
194
    final TreeItem<String> node = getCurrentNode();
195
196
    if( !node.isLeaf() ) {
197
      final String word = getLastPathWord();
198
      final String label = node.getValue();
199
      final int delta = difference( label, word );
200
      final String remainder = delta == NO_DIFFERENCE
201
        ? label
202
        : label.substring( delta );
203
204
      final StyledTextArea textArea = getEditor();
205
      final int posBegan = getCurrentCaretPosition();
206
      final int posEnded = posBegan + remainder.length();
207
208
      textArea.replaceSelection( remainder );
209
210
      if( posEnded - posBegan > 0 ) {
211
        textArea.selectRange( posEnded, posBegan );
212
      }
213
214
      expand( node );
215
    }
216
  }
217
218
  /**
219
   * Only variable name keys can pass through the filter. This is called when
220
   * the user presses a key.
221
   *
222
   * @param e The key that was pressed.
223
   */
224
  private void vModeFilterKeyPressed( final KeyEvent e ) {
225
    if( isVariableNameKey( e ) ) {
226
      typed( e.getText() );
227
    }
228
  }
229
230
  /**
231
   * Performs an autocomplete depending on whether the user has finished typing
232
   * in a word. If there is a selected range, then this will complete the most
233
   * recent word and jump to the next child.
234
   *
235
   * @return true The auto-completed node was a terminal node.
236
   */
237
  private boolean vModeConditionalComplete() {
238
    acceptPath();
239
240
    final TreeItem<String> node = getCurrentNode();
241
    final boolean terminal = isTerminal( node );
242
243
    if( !terminal ) {
244
      typed( SEPARATOR );
245
    }
246
247
    return terminal;
248
  }
249
250
  /**
251
   * Pressing control+space will find a node that matches the current word and
252
   * substitute the YAML variable reference. This is called when the user is not
253
   * editing in vMode.
254
   *
255
   * @param e Ignored -- it can only be Ctrl+Space.
256
   */
257
  private void autocomplete( final KeyEvent e ) {
258
    final String paragraph = getCaretParagraph();
259
    final int[] boundaries = getWordBoundaries( paragraph );
260
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
261
262
    final VariableTreeItem<String> leaf = findLeaf( word );
263
264
    if( leaf != null ) {
265
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
266
      decorateVariable();
267
      expand( leaf );
268
    }
269
  }
270
271
  /**
272
   * Called when autocomplete finishes on a valid leaf or when the user presses
273
   * Enter to finish manual autocomplete.
274
   */
275
  private void decorateVariable() {
276
    // A little bit of duplication...
277
    final String paragraph = getCaretParagraph();
278
    final int[] boundaries = getWordBoundaries( paragraph );
279
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
280
281
    final String newVariable = getVariableDecorator().decorate( old );
282
283
    final int posEnded = getCurrentCaretPosition();
284
    final int posBegan = posEnded - old.length();
285
286
    getEditor().replaceText( posBegan, posEnded, newVariable );
287
  }
288
289
  /**
290
   * Updates the text at the given position within the current paragraph.
291
   *
292
   * @param posBegan The starting index in the paragraph text to replace.
293
   * @param posEnded The ending index in the paragraph text to replace.
294
   * @param text Overwrite the paragraph substring with this text.
295
   */
296
  private void replaceText(
297
    final int posBegan, final int posEnded, final String text ) {
298
    final int p = getCurrentParagraph();
299
300
    getEditor().replaceText( p, posBegan, p, posEnded, text );
301
  }
302
303
  /**
304
   * Returns the caret's current paragraph position.
305
   *
306
   * @return A number greater than or equal to 0.
307
   */
308
  private int getCurrentParagraph() {
309
    return getEditor().getCurrentParagraph();
310
  }
311
312
  /**
313
   * Returns current word boundary indexes into the current paragraph, including
314
   * punctuation.
315
   *
316
   * @param p The paragraph wherein to hunt word boundaries.
317
   * @param offset The offset into the paragraph to begin scanning left and
318
   * right.
319
   *
320
   * @return The starting and ending index of the word closest to the caret.
321
   */
322
  private int[] getWordBoundaries( final String p, final int offset ) {
323
    // Remove dashes, but retain hyphens. Retain same number of characters
324
    // to preserve relative indexes.
325
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
326
327
    return getWordAt( paragraph, offset );
328
  }
329
330
  /**
331
   * Helper method to get the word boundaries for the current paragraph.
332
   *
333
   * @param paragraph
334
   *
335
   * @return
336
   */
337
  private int[] getWordBoundaries( final String paragraph ) {
338
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
339
  }
340
341
  /**
342
   * Given an arbitrary offset into a string, this returns the word at that
343
   * index. The inputs and outputs include:
344
   *
345
   * <ul>
346
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
347
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
348
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
349
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
350
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
351
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
352
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
353
   * </ul>
354
   *
355
   * @param p The string to scan for a word.
356
   * @param offset The offset within s to begin searching for the nearest word
357
   * boundary, must not be out of bounds of s.
358
   *
359
   * @return The word in s at the offset.
360
   *
361
   * @see getWordBegan( String, int )
362
   * @see getWordEnded( String, int )
363
   */
364
  private int[] getWordAt( final String p, final int offset ) {
365
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
366
  }
367
368
  /**
369
   * Returns the index into s where a word begins.
370
   *
371
   * @param s Never null.
372
   * @param offset Index into s to begin searching backwards for a word
373
   * boundary.
374
   *
375
   * @return The index where a word begins.
376
   */
377
  private int getWordBegan( final String s, int offset ) {
378
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
379
      offset--;
380
    }
381
382
    return offset;
383
  }
384
385
  /**
386
   * Returns the index into s where a word ends.
387
   *
388
   * @param s Never null.
389
   * @param offset Index into s to begin searching forwards for a word boundary.
390
   *
391
   * @return The index where a word ends.
392
   */
393
  private int getWordEnded( final String s, int offset ) {
394
    final int length = s.length();
395
396
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
397
      offset++;
398
    }
399
400
    return offset;
401
  }
402
403
  /**
404
   * Returns true if the given character can be reasonably expected to be part
405
   * of a word, including punctuation marks.
406
   *
407
   * @param c The character to compare.
408
   *
409
   * @return false The character is a space character.
410
   */
411
  private boolean isBoundary( final char c ) {
412
    return !isSpaceChar( c );
413
  }
414
415
  /**
416
   * Returns the text for the paragraph that contains the caret.
417
   *
418
   * @return A non-null string, possibly empty.
419
   */
420
  private String getCaretParagraph() {
421
    return getEditor().getText( getCurrentParagraph() );
422
  }
423
424
  /**
425
   * Returns true if the node has children that can be selected (i.e., any
426
   * non-leaves).
427
   *
428
   * @param <T> The type that the TreeItem contains.
429
   * @param node The node to test for terminality.
430
   *
431
   * @return true The node has one branch and its a leaf.
432
   */
433
  private <T> boolean isTerminal( final TreeItem<T> node ) {
434
    final ObservableList<TreeItem<T>> branches = node.getChildren();
435
436
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
437
  }
438
439
  /**
440
   * Inserts text that the user typed at the current caret position, then
441
   * performs an autocomplete for the variable name.
442
   *
443
   * @param text The text to insert, never null.
444
   */
445
  private void typed( final String text ) {
446
    getEditor().replaceSelection( text );
447
    vModeAutocomplete();
448
  }
449
450
  /**
451
   * Called when the user presses either End or Enter key.
452
   */
453
  private void acceptPath() {
454
    final IndexRange range = getSelectionRange();
455
456
    if( range != null ) {
457
      final int rangeEnd = range.getEnd();
458
      final StyledTextArea textArea = getEditor();
459
      textArea.deselect();
460
      textArea.moveTo( rangeEnd );
461
    }
462
  }
463
464
  /**
465
   * Replaces the entirety of the existing path (from the initial caret
466
   * position) with the given path.
467
   *
468
   * @param oldPath The path to replace.
469
   * @param newPath The replacement path.
470
   */
471
  private void replacePath( final String oldPath, final String newPath ) {
472
    final StyledTextArea textArea = getEditor();
473
    final int posBegan = getInitialCaretPosition();
474
    final int posEnded = posBegan + oldPath.length();
475
476
    textArea.deselect();
477
    textArea.replaceText( posBegan, posEnded, newPath );
478
  }
479
480
  /**
481
   * Called when the user presses the Backspace key.
482
   */
483
  private void deleteSelection() {
484
    final StyledTextArea textArea = getEditor();
485
    textArea.replaceSelection( "" );
486
    textArea.deletePreviousChar();
487
  }
488
489
  /**
490
   * Cycles the selected text through the nodes.
491
   *
492
   * @param direction true - next; false - previous
493
   */
494
  private void cycleSelection( final boolean direction ) {
495
    final TreeItem<String> node = getCurrentNode();
496
497
    // Find the sibling for the current selection and replace the current
498
    // selection with the sibling's value
499
    TreeItem< String> cycled = direction
500
      ? node.nextSibling()
501
      : node.previousSibling();
502
503
    // When cycling at the end (or beginning) of the list, jump to the first
504
    // (or last) sibling depending on the cycle direction.
505
    if( cycled == null ) {
506
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
507
    }
508
509
    final String path = getCurrentPath();
510
    final String cycledWord = cycled.getValue();
511
    final String word = getLastPathWord();
512
    final int index = path.indexOf( word );
513
    final String cycledPath = path.substring( 0, index ) + cycledWord;
514
515
    expand( cycled );
516
    replacePath( path, cycledPath );
517
  }
518
519
  /**
520
   * Cycles to the next sibling of the currently selected tree node.
521
   */
522
  private void cyclePathNext() {
523
    cycleSelection( true );
524
  }
525
526
  /**
527
   * Cycles to the previous sibling of the currently selected tree node.
528
   */
529
  private void cyclePathPrev() {
530
    cycleSelection( false );
531
  }
532
533
  /**
534
   * Returns the variable name (or as much as has been typed so far). Returns
535
   * all the characters from the initial caret column to the the first
536
   * whitespace character. This will return a path that contains zero or more
537
   * separators.
538
   *
539
   * @return A non-null string, possibly empty.
540
   */
541
  private String getCurrentPath() {
542
    final String s = extractTextChunk();
543
    final int length = s.length();
544
545
    int i = 0;
546
547
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
548
      i++;
549
    }
550
551
    return s.substring( 0, i );
552
  }
553
554
  private <T> ObservableList<TreeItem<T>> getSiblings(
555
    final TreeItem<T> item ) {
556
    final TreeItem<T> parent = item.getParent();
557
    return parent == null ? item.getChildren() : parent.getChildren();
558
  }
559
560
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
561
    return getFirst( getSiblings( item ), item );
562
  }
563
564
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
565
    return getLast( getSiblings( item ), item );
566
  }
567
568
  /**
569
   * Returns the caret position as an offset into the text.
570
   *
571
   * @return A value from 0 to the length of the text (minus one).
572
   */
573
  private int getCurrentCaretPosition() {
574
    return getEditor().getCaretPosition();
575
  }
576
577
  /**
578
   * Returns the caret position within the current paragraph.
579
   *
580
   * @return A value from 0 to the length of the current paragraph.
581
   */
582
  private int getCurrentCaretColumn() {
583
    return getEditor().getCaretColumn();
584
  }
585
586
  /**
587
   * Returns the last word from the path.
588
   *
589
   * @return The last token.
590
   */
591
  private String getLastPathWord() {
592
    String path = getCurrentPath();
593
594
    int i = path.indexOf( SEPARATOR );
595
596
    while( i > 0 ) {
597
      path = path.substring( i + 1 );
598
      i = path.indexOf( SEPARATOR );
599
    }
600
601
    return path;
602
  }
603
604
  /**
605
   * Returns text from the initial caret position until some arbitrarily long
606
   * number of characters. The number of characters extracted will be
607
   * getMaxVarLength, or fewer, depending on how many characters remain to be
608
   * extracted. The result from this method is trimmed to the first whitespace
609
   * character.
610
   *
611
   * @return A chunk of text that includes all the words representing a path,
612
   * and then some.
613
   */
614
  private String extractTextChunk() {
615
    final StyledTextArea textArea = getEditor();
616
    final int textBegan = getInitialCaretPosition();
617
    final int remaining = textArea.getLength() - textBegan;
618
    final int textEnded = min( remaining, getMaxVarLength() );
619
620
    return textArea.getText( textBegan, textEnded );
621
  }
622
623
  /**
624
   * Returns the node for the current path.
625
   */
626
  private TreeItem<String> getCurrentNode() {
627
    return findNode( getCurrentPath() );
628
  }
629
630
  /**
631
   * Finds the node that most closely matches the given path.
632
   *
633
   * @param path The path that represents a node.
634
   *
635
   * @return The node for the path, or the root node if the path could not be
636
   * found, but never null.
637
   */
638
  private TreeItem<String> findNode( final String path ) {
639
    return getDefinitionPane().findNode( path );
640
  }
641
642
  /**
643
   * Finds the first leaf having a value that starts with the given text.
644
   *
645
   * @param text The text to find in the definition tree.
646
   *
647
   * @return The leaf that starts with the given text, or null if not found.
648
   */
649
  private VariableTreeItem<String> findLeaf( final String text ) {
650
    return getDefinitionPane().findLeaf( text );
651
  }
652
653
  /**
654
   * Used to ignore typed keys in favour of trapping pressed keys.
655
   *
656
   * @param e The key that was typed.
657
   */
658
  private void vModeKeyTyped( KeyEvent e ) {
659
    e.consume();
660
  }
661
662
  /**
663
   * Used to lazily initialize the keyboard map.
664
   *
665
   * @return Mappings for keyTyped and keyPressed.
666
   */
667
  protected InputMap<InputEvent> createKeyboardMap() {
668
    return sequence(
669
      consume( keyTyped(), this::vModeKeyTyped ),
670
      consume( keyPressed(), this::vModeKeyPressed )
671
    );
672
  }
673
674
  private InputMap<InputEvent> getKeyboardMap() {
675
    if( this.keyboardMap == null ) {
676
      this.keyboardMap = createKeyboardMap();
677
    }
678
679
    return this.keyboardMap;
680
  }
681
682
  /**
683
   * Collapses the tree then expands and selects the given node.
684
   *
685
   * @param node The node to expand.
686
   */
687
  private void expand( final TreeItem<String> node ) {
688
    final DefinitionPane pane = getDefinitionPane();
689
    pane.collapse();
690
    pane.expand( node );
691
    pane.select( node );
692
  }
693
694
  /**
695
   * Returns true iff the key code the user typed can be used as part of a YAML
696
   * variable name.
697
   *
698
   * @param keyEvent Keyboard key press event information.
699
   *
700
   * @return true The key is a value that can be inserted into the text.
701
   */
702
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
703
    final KeyCode kc = keyEvent.getCode();
704
705
    return (kc.isLetterKey()
706
      || kc.isDigitKey()
707
      || (keyEvent.isShiftDown() && kc == MINUS))
708
      && !keyEvent.isControlDown();
709
  }
710
711
  /**
712
   * Starts to capture user input events.
713
   */
714
  private void vModeStart() {
715
    addEventListener( getKeyboardMap() );
716
  }
717
718
  /**
719
   * Restores capturing of user input events to the previous event listener.
720
   * Also asks the processing chain to modify the variable text into a
721
   * machine-readable variable based on the format required by the file type.
722
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
723
   * file (.Rmd, .Rxml) will use `r#xVAR`.
724
   */
725
  private void vModeStop() {
726
    removeEventListener( getKeyboardMap() );
727
  }
728
729
  private VariableDecorator getVariableDecorator() {
730
    return new YamlVariableDecorator();
731
  }
732
733
  /**
734
   * Returns the index where the two strings diverge.
735
   *
736
   * @param s1 The string that could be a substring of s2, null allowed.
737
   * @param s2 The string that could be a substring of s1, null allowed.
738
   *
739
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
740
   * where they differ.
741
   */
742
  @SuppressWarnings( "StringEquality" )
743
  private int difference( final CharSequence s1, final CharSequence s2 ) {
744
    if( s1 == s2 ) {
745
      return NO_DIFFERENCE;
746
    }
747
748
    if( s1 == null || s2 == null ) {
749
      return 0;
750
    }
751
752
    int i = 0;
753
    final int limit = min( s1.length(), s2.length() );
754
755
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
756
      i++;
757
    }
758
759
    // If one string was shorter than the other, that's where they differ.
760
    return i;
761
  }
762
  
37
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR;
38
import com.scrivenvar.service.Settings;
39
import static com.scrivenvar.util.Lists.getFirst;
40
import static com.scrivenvar.util.Lists.getLast;
41
import static java.lang.Character.isSpaceChar;
42
import static java.lang.Character.isWhitespace;
43
import static java.lang.Math.min;
44
import java.util.function.Consumer;
45
import javafx.collections.ObservableList;
46
import javafx.event.Event;
47
import javafx.scene.control.IndexRange;
48
import javafx.scene.control.TreeItem;
49
import javafx.scene.input.InputEvent;
50
import javafx.scene.input.KeyCode;
51
import static javafx.scene.input.KeyCode.AT;
52
import static javafx.scene.input.KeyCode.DIGIT2;
53
import static javafx.scene.input.KeyCode.ENTER;
54
import static javafx.scene.input.KeyCode.MINUS;
55
import static javafx.scene.input.KeyCode.SPACE;
56
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
57
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
58
import javafx.scene.input.KeyEvent;
59
import org.fxmisc.richtext.StyledTextArea;
60
import org.fxmisc.wellbehaved.event.EventPattern;
61
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
62
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
63
import org.fxmisc.wellbehaved.event.InputMap;
64
import static org.fxmisc.wellbehaved.event.InputMap.consume;
65
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
66
67
/**
68
 * Provides the logic for injecting variable names within the editor.
69
 *
70
 * @author White Magic Software, Ltd.
71
 */
72
public class VariableNameInjector {
73
74
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
75
76
  private static final int NO_DIFFERENCE = -1;
77
78
  private final Settings settings = Services.load( Settings.class );
79
80
  /**
81
   * Used to capture keyboard events once the user presses @.
82
   */
83
  private InputMap<InputEvent> keyboardMap;
84
85
  private FileEditorTab tab;
86
  private DefinitionPane definitionPane;
87
88
  /**
89
   * Position of the variable in the text when in variable mode (0 by default).
90
   */
91
  private int initialCaretPosition;
92
93
  private VariableNameInjector() {
94
  }
95
96
  public static void listen( final FileEditorTab tab, final DefinitionPane pane ) {
97
    VariableNameInjector vni = new VariableNameInjector();
98
99
    vni.setFileEditorTab( tab );
100
    vni.setDefinitionPane( pane );
101
102
    vni.initKeyboardEventListeners();
103
  }
104
105
  /**
106
   * Traps keys for performing various short-cut tasks, such as @-mode variable
107
   * insertion and control+space for variable autocomplete.
108
   *
109
   * @ key is pressed, a new keyboard map is inserted in place of the current
110
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
111
   *
112
   * @see createKeyboardMap()
113
   */
114
  private void initKeyboardEventListeners() {
115
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
116
117
    // @ key in Linux?
118
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
119
    // @ key in Windows.
120
    addEventListener( keyPressed( AT ), this::vMode );
121
  }
122
123
  /**
124
   * The @ symbol is a short-cut to inserting a YAML variable reference.
125
   *
126
   * @param e Superfluous information about the key that was pressed.
127
   */
128
  private void vMode( KeyEvent e ) {
129
    setInitialCaretPosition();
130
    vModeStart();
131
    vModeAutocomplete();
132
  }
133
134
  /**
135
   * Receives key presses until the user completes the variable selection. This
136
   * allows the arrow keys to be used for selecting variables.
137
   *
138
   * @param e The key that was pressed.
139
   */
140
  private void vModeKeyPressed( KeyEvent e ) {
141
    final KeyCode keyCode = e.getCode();
142
143
    switch( keyCode ) {
144
      case BACK_SPACE:
145
        // Don't decorate the variable upon exiting vMode.
146
        vModeBackspace();
147
        break;
148
149
      case ESCAPE:
150
        // Don't decorate the variable upon exiting vMode.
151
        vModeStop();
152
        break;
153
154
      case ENTER:
155
      case PERIOD:
156
      case RIGHT:
157
      case END:
158
        // Stop at a leaf node, ENTER means accept.
159
        if( vModeConditionalComplete() && keyCode == ENTER ) {
160
          vModeStop();
161
162
          // Decorate the variable upon exiting vMode.
163
          decorateVariable();
164
        }
165
        break;
166
167
      case UP:
168
        cyclePathPrev();
169
        break;
170
171
      case DOWN:
172
        cyclePathNext();
173
        break;
174
175
      default:
176
        vModeFilterKeyPressed( e );
177
        break;
178
    }
179
180
    e.consume();
181
  }
182
183
  private void vModeBackspace() {
184
    deleteSelection();
185
186
    // Break out of variable mode by back spacing to the original position.
187
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
188
      vModeAutocomplete();
189
    } else {
190
      vModeStop();
191
    }
192
  }
193
194
  /**
195
   * Updates the text with the path selected (or typed) by the user.
196
   */
197
  private void vModeAutocomplete() {
198
    final TreeItem<String> node = getCurrentNode();
199
200
    if( !node.isLeaf() ) {
201
      final String word = getLastPathWord();
202
      final String label = node.getValue();
203
      final int delta = difference( label, word );
204
      final String remainder = delta == NO_DIFFERENCE
205
        ? label
206
        : label.substring( delta );
207
208
      final StyledTextArea textArea = getEditor();
209
      final int posBegan = getCurrentCaretPosition();
210
      final int posEnded = posBegan + remainder.length();
211
212
      textArea.replaceSelection( remainder );
213
214
      if( posEnded - posBegan > 0 ) {
215
        textArea.selectRange( posEnded, posBegan );
216
      }
217
218
      expand( node );
219
    }
220
  }
221
222
  /**
223
   * Only variable name keys can pass through the filter. This is called when
224
   * the user presses a key.
225
   *
226
   * @param e The key that was pressed.
227
   */
228
  private void vModeFilterKeyPressed( final KeyEvent e ) {
229
    if( isVariableNameKey( e ) ) {
230
      typed( e.getText() );
231
    }
232
  }
233
234
  /**
235
   * Performs an autocomplete depending on whether the user has finished typing
236
   * in a word. If there is a selected range, then this will complete the most
237
   * recent word and jump to the next child.
238
   *
239
   * @return true The auto-completed node was a terminal node.
240
   */
241
  private boolean vModeConditionalComplete() {
242
    acceptPath();
243
244
    final TreeItem<String> node = getCurrentNode();
245
    final boolean terminal = isTerminal( node );
246
247
    if( !terminal ) {
248
      typed( SEPARATOR );
249
    }
250
251
    return terminal;
252
  }
253
254
  /**
255
   * Pressing control+space will find a node that matches the current word and
256
   * substitute the YAML variable reference. This is called when the user is not
257
   * editing in vMode.
258
   *
259
   * @param e Ignored -- it can only be Ctrl+Space.
260
   */
261
  private void autocomplete( final KeyEvent e ) {
262
    final String paragraph = getCaretParagraph();
263
    final int[] boundaries = getWordBoundaries( paragraph );
264
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
265
266
    final VariableTreeItem<String> leaf = findLeaf( word );
267
268
    if( leaf != null ) {
269
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
270
      decorateVariable();
271
      expand( leaf );
272
    }
273
  }
274
275
  /**
276
   * Called when autocomplete finishes on a valid leaf or when the user presses
277
   * Enter to finish manual autocomplete.
278
   */
279
  private void decorateVariable() {
280
    // A little bit of duplication...
281
    final String paragraph = getCaretParagraph();
282
    final int[] boundaries = getWordBoundaries( paragraph );
283
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
284
285
    final String newVariable = getVariableDecorator().decorate( old );
286
287
    final int posEnded = getCurrentCaretPosition();
288
    final int posBegan = posEnded - old.length();
289
290
    getEditor().replaceText( posBegan, posEnded, newVariable );
291
  }
292
293
  /**
294
   * Updates the text at the given position within the current paragraph.
295
   *
296
   * @param posBegan The starting index in the paragraph text to replace.
297
   * @param posEnded The ending index in the paragraph text to replace.
298
   * @param text Overwrite the paragraph substring with this text.
299
   */
300
  private void replaceText(
301
    final int posBegan, final int posEnded, final String text ) {
302
    final int p = getCurrentParagraph();
303
304
    getEditor().replaceText( p, posBegan, p, posEnded, text );
305
  }
306
307
  /**
308
   * Returns the caret's current paragraph position.
309
   *
310
   * @return A number greater than or equal to 0.
311
   */
312
  private int getCurrentParagraph() {
313
    return getEditor().getCurrentParagraph();
314
  }
315
316
  /**
317
   * Returns current word boundary indexes into the current paragraph, including
318
   * punctuation.
319
   *
320
   * @param p The paragraph wherein to hunt word boundaries.
321
   * @param offset The offset into the paragraph to begin scanning left and
322
   * right.
323
   *
324
   * @return The starting and ending index of the word closest to the caret.
325
   */
326
  private int[] getWordBoundaries( final String p, final int offset ) {
327
    // Remove dashes, but retain hyphens. Retain same number of characters
328
    // to preserve relative indexes.
329
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
330
331
    return getWordAt( paragraph, offset );
332
  }
333
334
  /**
335
   * Helper method to get the word boundaries for the current paragraph.
336
   *
337
   * @param paragraph
338
   *
339
   * @return
340
   */
341
  private int[] getWordBoundaries( final String paragraph ) {
342
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
343
  }
344
345
  /**
346
   * Given an arbitrary offset into a string, this returns the word at that
347
   * index. The inputs and outputs include:
348
   *
349
   * <ul>
350
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
351
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
352
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
353
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
354
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
355
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
356
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
357
   * </ul>
358
   *
359
   * @param p The string to scan for a word.
360
   * @param offset The offset within s to begin searching for the nearest word
361
   * boundary, must not be out of bounds of s.
362
   *
363
   * @return The word in s at the offset.
364
   *
365
   * @see getWordBegan( String, int )
366
   * @see getWordEnded( String, int )
367
   */
368
  private int[] getWordAt( final String p, final int offset ) {
369
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
370
  }
371
372
  /**
373
   * Returns the index into s where a word begins.
374
   *
375
   * @param s Never null.
376
   * @param offset Index into s to begin searching backwards for a word
377
   * boundary.
378
   *
379
   * @return The index where a word begins.
380
   */
381
  private int getWordBegan( final String s, int offset ) {
382
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
383
      offset--;
384
    }
385
386
    return offset;
387
  }
388
389
  /**
390
   * Returns the index into s where a word ends.
391
   *
392
   * @param s Never null.
393
   * @param offset Index into s to begin searching forwards for a word boundary.
394
   *
395
   * @return The index where a word ends.
396
   */
397
  private int getWordEnded( final String s, int offset ) {
398
    final int length = s.length();
399
400
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
401
      offset++;
402
    }
403
404
    return offset;
405
  }
406
407
  /**
408
   * Returns true if the given character can be reasonably expected to be part
409
   * of a word, including punctuation marks.
410
   *
411
   * @param c The character to compare.
412
   *
413
   * @return false The character is a space character.
414
   */
415
  private boolean isBoundary( final char c ) {
416
    return !isSpaceChar( c );
417
  }
418
419
  /**
420
   * Returns the text for the paragraph that contains the caret.
421
   *
422
   * @return A non-null string, possibly empty.
423
   */
424
  private String getCaretParagraph() {
425
    return getEditor().getText( getCurrentParagraph() );
426
  }
427
428
  /**
429
   * Returns true if the node has children that can be selected (i.e., any
430
   * non-leaves).
431
   *
432
   * @param <T> The type that the TreeItem contains.
433
   * @param node The node to test for terminality.
434
   *
435
   * @return true The node has one branch and its a leaf.
436
   */
437
  private <T> boolean isTerminal( final TreeItem<T> node ) {
438
    final ObservableList<TreeItem<T>> branches = node.getChildren();
439
440
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
441
  }
442
443
  /**
444
   * Inserts text that the user typed at the current caret position, then
445
   * performs an autocomplete for the variable name.
446
   *
447
   * @param text The text to insert, never null.
448
   */
449
  private void typed( final String text ) {
450
    getEditor().replaceSelection( text );
451
    vModeAutocomplete();
452
  }
453
454
  /**
455
   * Called when the user presses either End or Enter key.
456
   */
457
  private void acceptPath() {
458
    final IndexRange range = getSelectionRange();
459
460
    if( range != null ) {
461
      final int rangeEnd = range.getEnd();
462
      final StyledTextArea textArea = getEditor();
463
      textArea.deselect();
464
      textArea.moveTo( rangeEnd );
465
    }
466
  }
467
468
  /**
469
   * Replaces the entirety of the existing path (from the initial caret
470
   * position) with the given path.
471
   *
472
   * @param oldPath The path to replace.
473
   * @param newPath The replacement path.
474
   */
475
  private void replacePath( final String oldPath, final String newPath ) {
476
    final StyledTextArea textArea = getEditor();
477
    final int posBegan = getInitialCaretPosition();
478
    final int posEnded = posBegan + oldPath.length();
479
480
    textArea.deselect();
481
    textArea.replaceText( posBegan, posEnded, newPath );
482
  }
483
484
  /**
485
   * Called when the user presses the Backspace key.
486
   */
487
  private void deleteSelection() {
488
    final StyledTextArea textArea = getEditor();
489
    textArea.replaceSelection( "" );
490
    textArea.deletePreviousChar();
491
  }
492
493
  /**
494
   * Cycles the selected text through the nodes.
495
   *
496
   * @param direction true - next; false - previous
497
   */
498
  private void cycleSelection( final boolean direction ) {
499
    final TreeItem<String> node = getCurrentNode();
500
501
    // Find the sibling for the current selection and replace the current
502
    // selection with the sibling's value
503
    TreeItem< String> cycled = direction
504
      ? node.nextSibling()
505
      : node.previousSibling();
506
507
    // When cycling at the end (or beginning) of the list, jump to the first
508
    // (or last) sibling depending on the cycle direction.
509
    if( cycled == null ) {
510
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
511
    }
512
513
    final String path = getCurrentPath();
514
    final String cycledWord = cycled.getValue();
515
    final String word = getLastPathWord();
516
    final int index = path.indexOf( word );
517
    final String cycledPath = path.substring( 0, index ) + cycledWord;
518
519
    expand( cycled );
520
    replacePath( path, cycledPath );
521
  }
522
523
  /**
524
   * Cycles to the next sibling of the currently selected tree node.
525
   */
526
  private void cyclePathNext() {
527
    cycleSelection( true );
528
  }
529
530
  /**
531
   * Cycles to the previous sibling of the currently selected tree node.
532
   */
533
  private void cyclePathPrev() {
534
    cycleSelection( false );
535
  }
536
537
  /**
538
   * Returns the variable name (or as much as has been typed so far). Returns
539
   * all the characters from the initial caret column to the the first
540
   * whitespace character. This will return a path that contains zero or more
541
   * separators.
542
   *
543
   * @return A non-null string, possibly empty.
544
   */
545
  private String getCurrentPath() {
546
    final String s = extractTextChunk();
547
    final int length = s.length();
548
549
    int i = 0;
550
551
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
552
      i++;
553
    }
554
555
    return s.substring( 0, i );
556
  }
557
558
  private <T> ObservableList<TreeItem<T>> getSiblings(
559
    final TreeItem<T> item ) {
560
    final TreeItem<T> parent = item.getParent();
561
    return parent == null ? item.getChildren() : parent.getChildren();
562
  }
563
564
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
565
    return getFirst( getSiblings( item ), item );
566
  }
567
568
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
569
    return getLast( getSiblings( item ), item );
570
  }
571
572
  /**
573
   * Returns the caret position as an offset into the text.
574
   *
575
   * @return A value from 0 to the length of the text (minus one).
576
   */
577
  private int getCurrentCaretPosition() {
578
    return getEditor().getCaretPosition();
579
  }
580
581
  /**
582
   * Returns the caret position within the current paragraph.
583
   *
584
   * @return A value from 0 to the length of the current paragraph.
585
   */
586
  private int getCurrentCaretColumn() {
587
    return getEditor().getCaretColumn();
588
  }
589
590
  /**
591
   * Returns the last word from the path.
592
   *
593
   * @return The last token.
594
   */
595
  private String getLastPathWord() {
596
    String path = getCurrentPath();
597
598
    int i = path.indexOf( SEPARATOR_CHAR );
599
600
    while( i > 0 ) {
601
      path = path.substring( i + 1 );
602
      i = path.indexOf( SEPARATOR_CHAR );
603
    }
604
605
    return path;
606
  }
607
608
  /**
609
   * Returns text from the initial caret position until some arbitrarily long
610
   * number of characters. The number of characters extracted will be
611
   * getMaxVarLength, or fewer, depending on how many characters remain to be
612
   * extracted. The result from this method is trimmed to the first whitespace
613
   * character.
614
   *
615
   * @return A chunk of text that includes all the words representing a path,
616
   * and then some.
617
   */
618
  private String extractTextChunk() {
619
    final StyledTextArea textArea = getEditor();
620
    final int textBegan = getInitialCaretPosition();
621
    final int remaining = textArea.getLength() - textBegan;
622
    final int textEnded = min( remaining, getMaxVarLength() );
623
624
    return textArea.getText( textBegan, textEnded );
625
  }
626
627
  /**
628
   * Returns the node for the current path.
629
   */
630
  private TreeItem<String> getCurrentNode() {
631
    return findNode( getCurrentPath() );
632
  }
633
634
  /**
635
   * Finds the node that most closely matches the given path.
636
   *
637
   * @param path The path that represents a node.
638
   *
639
   * @return The node for the path, or the root node if the path could not be
640
   * found, but never null.
641
   */
642
  private TreeItem<String> findNode( final String path ) {
643
    return getDefinitionPane().findNode( path );
644
  }
645
646
  /**
647
   * Finds the first leaf having a value that starts with the given text.
648
   *
649
   * @param text The text to find in the definition tree.
650
   *
651
   * @return The leaf that starts with the given text, or null if not found.
652
   */
653
  private VariableTreeItem<String> findLeaf( final String text ) {
654
    return getDefinitionPane().findLeaf( text );
655
  }
656
657
  /**
658
   * Used to ignore typed keys in favour of trapping pressed keys.
659
   *
660
   * @param e The key that was typed.
661
   */
662
  private void vModeKeyTyped( KeyEvent e ) {
663
    e.consume();
664
  }
665
666
  /**
667
   * Used to lazily initialize the keyboard map.
668
   *
669
   * @return Mappings for keyTyped and keyPressed.
670
   */
671
  protected InputMap<InputEvent> createKeyboardMap() {
672
    return sequence(
673
      consume( keyTyped(), this::vModeKeyTyped ),
674
      consume( keyPressed(), this::vModeKeyPressed )
675
    );
676
  }
677
678
  private InputMap<InputEvent> getKeyboardMap() {
679
    if( this.keyboardMap == null ) {
680
      this.keyboardMap = createKeyboardMap();
681
    }
682
683
    return this.keyboardMap;
684
  }
685
686
  /**
687
   * Collapses the tree then expands and selects the given node.
688
   *
689
   * @param node The node to expand.
690
   */
691
  private void expand( final TreeItem<String> node ) {
692
    final DefinitionPane pane = getDefinitionPane();
693
    pane.collapse();
694
    pane.expand( node );
695
    pane.select( node );
696
  }
697
698
  /**
699
   * Returns true iff the key code the user typed can be used as part of a YAML
700
   * variable name.
701
   *
702
   * @param keyEvent Keyboard key press event information.
703
   *
704
   * @return true The key is a value that can be inserted into the text.
705
   */
706
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
707
    final KeyCode kc = keyEvent.getCode();
708
709
    return (kc.isLetterKey()
710
      || kc.isDigitKey()
711
      || (keyEvent.isShiftDown() && kc == MINUS))
712
      && !keyEvent.isControlDown();
713
  }
714
715
  /**
716
   * Starts to capture user input events.
717
   */
718
  private void vModeStart() {
719
    addEventListener( getKeyboardMap() );
720
  }
721
722
  /**
723
   * Restores capturing of user input events to the previous event listener.
724
   * Also asks the processing chain to modify the variable text into a
725
   * machine-readable variable based on the format required by the file type.
726
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
727
   * file (.Rmd, .Rxml) will use `r#xVAR`.
728
   */
729
  private void vModeStop() {
730
    removeEventListener( getKeyboardMap() );
731
  }
732
733
  private VariableDecorator getVariableDecorator() {
734
    return new YamlVariableDecorator();
735
  }
736
737
  /**
738
   * Returns the index where the two strings diverge.
739
   *
740
   * @param s1 The string that could be a substring of s2, null allowed.
741
   * @param s2 The string that could be a substring of s1, null allowed.
742
   *
743
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
744
   * where they differ.
745
   */
746
  @SuppressWarnings( "StringEquality" )
747
  private int difference( final CharSequence s1, final CharSequence s2 ) {
748
    if( s1 == s2 ) {
749
      return NO_DIFFERENCE;
750
    }
751
752
    if( s1 == null || s2 == null ) {
753
      return 0;
754
    }
755
756
    int i = 0;
757
    final int limit = min( s1.length(), s2.length() );
758
759
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
760
      i++;
761
    }
762
763
    // If one string was shorter than the other, that's where they differ.
764
    return i;
765
  }
766
763767
  private EditorPane getEditorPane() {
764768
    return getFileEditorTab().getEditorPane();
A src/main/java/com/scrivenvar/processors/CaretInsertionProcessor.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 static com.scrivenvar.Constants.CARET_POSITION_MD;
31
32
/**
33
 * Base class for inserting the magic CARET POSITION into the text so that, upon
34
 * previewing, the preview pane can scroll to the correct position (relative to
35
 * the caret position in the editor).
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public abstract class CaretInsertionProcessor extends AbstractProcessor<String> {
40
41
  private final int caretPosition;
42
43
  public CaretInsertionProcessor(
44
    final Processor<String> processor, final int position ) {
45
    super( processor );
46
    this.caretPosition = position;
47
  }
48
49
  /**
50
   * Inserts the caret position token into the text at an offset that won't
51
   * interfere with parsing the text itself, regardless of text format.
52
   *
53
   * @param text The text document to change.
54
   * @param i The caret position token insertion point to use, or -1 to
55
   * return the text without any injection.
56
   *
57
   * @return The given text with a caret position token inserted at the given
58
   * offset.
59
   */
60
  protected String inject( final String text, final int i ) {
61
    return i > 0 && i <= text.length()
62
      ? new StringBuilder( text ).replace( i, i, CARET_POSITION_MD ).toString()
63
      : text;
64
  }
65
66
  /**
67
   * Returns the editor's caret position.
68
   *
69
   * @return Where the user has positioned the caret.
70
   */
71
  protected int getCaretPosition() {
72
    return this.caretPosition;
73
  }
74
}
175
A src/main/java/com/scrivenvar/processors/CaretReplacementProcessor.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 static com.scrivenvar.Constants.CARET_POSITION_HTML;
31
import static com.scrivenvar.Constants.CARET_POSITION_MD;
32
33
/**
34
 * Responsible for replacing the caret position marker with an HTML element
35
 * suitable to use as a reference for scrolling a view port.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public class CaretReplacementProcessor extends AbstractProcessor<String> {
40
  private static final int INDEX_NOT_FOUND = -1;
41
42
  public CaretReplacementProcessor( final Processor<String> processor ) {
43
    super( processor );
44
  }
45
46
  /**
47
   * Replaces each MD_CARET_POSITION with an HTML element that has an id
48
   * attribute of CARET_POSITION. This should only replace one item.
49
   *
50
   * @param t The text that contains
51
   *
52
   * @return
53
   */
54
  @Override
55
  public String processLink( final String t ) {
56
    return replace(t, CARET_POSITION_MD, CARET_POSITION_HTML );
57
  }
58
59
  /**
60
   * Replaces the needle with thread in the given haystack. Based on Apache
61
   * Commons 3 StringUtils.replace method. Should be faster than
62
   * String.replace, which performs a little regex under the hood.
63
   *
64
   * @param haystack Search this string for the needle, must not be null.
65
   * @param needle The text to find in the haystack.
66
   * @param thread Replace the needle with this text, if the needle is found.
67
   *
68
   * @return The haystack with the first instance of needle replaced with
69
   * thread.
70
   */
71
  private static String replace(
72
    final String haystack, final String needle, final String thread ) {
73
74
    final int end = haystack.indexOf( needle, 0 );
75
76
    if( end == INDEX_NOT_FOUND ) {
77
      return haystack;
78
    }
79
80
    int start = 0;
81
    final int needleLength = needle.length();
82
83
    int increase = thread.length() - needleLength;
84
    increase = (increase < 0 ? 0 : increase);
85
    final StringBuilder buffer = new StringBuilder( haystack.length() + increase );
86
87
    if( end != INDEX_NOT_FOUND ) {
88
      buffer.append( haystack.substring( start, end ) ).append( thread );
89
      start = end + needleLength;
90
    }
91
92
    return buffer.append( haystack.substring( start ) ).toString();
93
  }
94
}
195
M src/main/java/com/scrivenvar/processors/MarkdownCaretInsertionProcessor.java
2828
package com.scrivenvar.processors;
2929
30
import static com.scrivenvar.Constants.CARET_POSITION_MD;
3130
import static java.lang.Character.isLetter;
3231
import static java.lang.Math.min;
3332
3433
/**
35
 * Responsible for inserting the magic CARET POSITION into the markdown so that,
36
 * upon rendering into HTML, the HTML pane can scroll to the correct position
37
 * (relative to the caret position in the editor).
34
 * Responsible for inserting a caret position token into a markdown document.
3835
 *
3936
 * @author White Magic Software, Ltd.
4037
 */
41
public class MarkdownCaretInsertionProcessor extends AbstractProcessor<String> {
42
43
  private final int caretPosition;
38
public  class MarkdownCaretInsertionProcessor extends CaretInsertionProcessor {
4439
4540
  /**
4641
   * Constructs a processor capable of inserting a caret marker into Markdown.
4742
   *
4843
   * @param processor The next processor in the chain.
49
   * @param position The caret's current position in the text, cannot be null.
44
   * @param position The caret's current position in the text.
5045
   */
5146
  public MarkdownCaretInsertionProcessor(
5247
    final Processor<String> processor, final int position ) {
53
    super( processor );
54
    this.caretPosition = position;
48
    super( processor, position );
5549
  }
5650
5751
  /**
5852
   * Changes the text to insert a "caret" at the caret position. This will
5953
   * insert the unique key of Constants.MD_CARET_POSITION into the document.
6054
   *
61
   * @param t The document text to process.
55
   * @param t The text document to process.
6256
   *
63
   * @return The document text with the Markdown caret text inserted at the
64
   * caret position (given at construction time).
57
   * @return The text with the caret position token inserted at the caret
58
   * position.
6559
   */
6660
  @Override
...
7973
    // 4. Find the nearest text node to the caret.
8074
    // 5. Insert the CARET_POSITION_MD value in the text at that offsset.
81
8275
    // Insert the caret at the closest non-Markdown delimiter (i.e., the 
8376
    // closest character from the caret position forward).
8477
    while( offset < length && !isLetter( t.charAt( offset ) ) ) {
8578
      offset++;
8679
    }
87
88
    // Insert the caret position into the Markdown text, but don't interfere
89
    // with the Markdown iteself.
90
    return new StringBuilder( t ).replace(
91
      offset, offset, CARET_POSITION_MD ).toString();
92
  }
9380
94
  /**
95
   * Returns the editor's caret position.
96
   *
97
   * @return Where the user has positioned the caret.
98
   */
99
  private int getCaretPosition() {
100
    return this.caretPosition;
81
    return inject( t, offset );
10182
  }
10283
}
D src/main/java/com/scrivenvar/processors/MarkdownCaretReplacementProcessor.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 static com.scrivenvar.Constants.CARET_POSITION_HTML;
31
import static com.scrivenvar.Constants.CARET_POSITION_MD;
32
33
/**
34
 * Responsible for replacing the caret position marker with an HTML element
35
 * suitable to use as a reference for scrolling a view port.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public class MarkdownCaretReplacementProcessor extends AbstractProcessor<String> {
40
  private static final int INDEX_NOT_FOUND = -1;
41
42
  public MarkdownCaretReplacementProcessor( final Processor<String> processor ) {
43
    super( processor );
44
  }
45
46
  /**
47
   * Replaces each MD_CARET_POSITION with an HTML element that has an id
48
   * attribute of CARET_POSITION. This should only replace one item.
49
   *
50
   * @param t The text that contains
51
   *
52
   * @return
53
   */
54
  @Override
55
  public String processLink( final String t ) {
56
    return replace(t, CARET_POSITION_MD, CARET_POSITION_HTML );
57
  }
58
59
  /**
60
   * Replaces the needle with thread in the given haystack. Based on Apache
61
   * Commons 3 StringUtils.replace method. Should be faster than
62
   * String.replace, which performs a little regex under the hood.
63
   *
64
   * @param haystack Search this string for the needle, must not be null.
65
   * @param needle The text to find in the haystack.
66
   * @param thread Replace the needle with this text, if the needle is found.
67
   *
68
   * @return The haystack with the first instance of needle replaced with
69
   * thread.
70
   */
71
  private static String replace(
72
    final String haystack, final String needle, final String thread ) {
73
74
    final int end = haystack.indexOf( needle, 0 );
75
76
    if( end == INDEX_NOT_FOUND ) {
77
      return haystack;
78
    }
79
80
    int start = 0;
81
    final int needleLength = needle.length();
82
83
    int increase = thread.length() - needleLength;
84
    increase = (increase < 0 ? 0 : increase);
85
    final StringBuilder buffer = new StringBuilder( haystack.length() + increase );
86
87
    if( end != INDEX_NOT_FOUND ) {
88
      buffer.append( haystack.substring( start, end ) ).append( thread );
89
      start = end + needleLength;
90
    }
91
92
    return buffer.append( haystack.substring( start ) ).toString();
93
  }
94
}
951
A src/main/java/com/scrivenvar/processors/XMLCaretInsertionProcessor.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 com.scrivenvar.FileEditorTab;
31
import com.ximpleware.VTDException;
32
import com.ximpleware.VTDGen;
33
import static com.ximpleware.VTDGen.TOKEN_CHARACTER_DATA;
34
import com.ximpleware.VTDNav;
35
import java.text.ParseException;
36
37
/**
38
 * Inserts a caret position indicator into the document.
39
 *
40
 * @author White Magic Software, Ltd.
41
 */
42
public class XMLCaretInsertionProcessor extends CaretInsertionProcessor {
43
44
  private FileEditorTab tab;
45
46
  /**
47
   * Constructs a processor capable of inserting a caret marker into XML.
48
   *
49
   * @param processor The next processor in the chain.
50
   * @param position The caret's current position in the text, cannot be null.
51
   */
52
  public XMLCaretInsertionProcessor(
53
    final Processor<String> processor, final int position ) {
54
    super( processor, position );
55
  }
56
57
  /**
58
   * Inserts a caret at a valid position within the XML document.
59
   *
60
   * @param t The string into which caret position marker text is inserted.
61
   *
62
   * @return t with a caret position marker included, or t if no place to insert
63
   * could be found.
64
   */
65
  @Override
66
  public String processLink( final String t ) {
67
    final int caret = getCaretPosition();
68
    int insertOffset = -1;
69
70
    if( t.length() > 0 ) {
71
72
      try {
73
        final VTDNav vn = getNavigator( t );
74
        final int tokens = vn.getTokenCount();
75
76
        int currTokenIndex = 0;
77
        int prevTokenIndex = currTokenIndex;
78
        int currOffset = 0;
79
80
        // To find the insertion spot even faster, the algorithm could
81
        // use a binary search or interpolation search algorithm. This
82
        // would reduce the worst-case iterations to O(log n) from O(n).
83
        while( currTokenIndex < tokens ) {
84
          if( vn.getTokenType( currTokenIndex ) == TOKEN_CHARACTER_DATA ) {
85
            final int prevOffset = currOffset;
86
            currOffset = vn.getTokenOffset( currTokenIndex );
87
88
            if( currOffset > caret ) {
89
              final int prevLength = vn.getTokenLength( prevTokenIndex );
90
91
              // If the caret falls within the limits of the previous token, then
92
              // insert the caret position marker at the caret offset.
93
              if( isBetween( caret, prevOffset, prevOffset + prevLength ) ) {
94
                insertOffset = caret;
95
              } else {
96
                // The caret position is outside the previous token's text
97
                // boundaries, but not inside the current text token. The
98
                // caret should be positioned into the closer text token.
99
                // For now, the cursor is positioned at the start of the
100
                // current text token.
101
                insertOffset = currOffset;
102
              }
103
              
104
              break;
105
            }
106
107
            prevTokenIndex = currTokenIndex;
108
          }
109
110
          currTokenIndex++;
111
        }
112
113
      } catch( final Exception ex ) {
114
        throw new RuntimeException(
115
          new ParseException( ex.getMessage(), caret )
116
        );
117
      }
118
    }
119
120
    return inject( t, insertOffset );
121
  }
122
123
  private boolean isBetween( int i, int min, int max ) {
124
    return i >= min && i <= max;
125
  }
126
127
  /**
128
   * Parses the given XML document and returns a high-performance navigator
129
   * instance for scanning through the XML elements.
130
   *
131
   * @param xml The XML document to parse.
132
   *
133
   * @return A document navigator instance.
134
   */
135
  private VTDNav getNavigator( final String xml ) throws VTDException {
136
    final VTDGen vg = getParser();
137
138
    // TODO: Use the document's encoding...
139
    vg.setDoc( xml.getBytes() );
140
    vg.parse( true );
141
    return vg.getNav();
142
  }
143
144
  private VTDGen getParser() {
145
    return new VTDGen();
146
  }
147
}
1148
A src/main/java/com/scrivenvar/processors/XMLProcessor.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 com.scrivenvar.Services;
31
import com.scrivenvar.service.Snitch;
32
import java.io.File;
33
import java.io.Reader;
34
import java.io.StringReader;
35
import java.io.StringWriter;
36
import java.nio.file.Path;
37
import java.nio.file.Paths;
38
import java.text.ParseException;
39
import javax.xml.stream.XMLEventReader;
40
import javax.xml.stream.XMLInputFactory;
41
import javax.xml.stream.XMLStreamException;
42
import javax.xml.stream.events.ProcessingInstruction;
43
import javax.xml.stream.events.XMLEvent;
44
import javax.xml.transform.Source;
45
import javax.xml.transform.Transformer;
46
import javax.xml.transform.TransformerConfigurationException;
47
import javax.xml.transform.TransformerFactory;
48
import javax.xml.transform.stream.StreamResult;
49
import javax.xml.transform.stream.StreamSource;
50
import net.sf.saxon.TransformerFactoryImpl;
51
import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute;
52
53
/**
54
 * Transforms an XML document. The XML document must have a stylesheet specified
55
 * as part of its processing instructions, such as:
56
 *
57
 * <code>xml-stylesheet type="text/xsl" href="markdown.xsl"</code>
58
 *
59
 * The XSL must transform the XML document into Markdown, or another format
60
 * recognized by the next link on the chain.
61
 *
62
 * @author White Magic Software, Ltd.
63
 */
64
public class XMLProcessor extends AbstractProcessor<String> {
65
  
66
  private final Snitch snitch = Services.load( Snitch.class );
67
  
68
  private XMLInputFactory xmlInputFactory;
69
  private TransformerFactory transformerFactory;
70
  
71
  private Path path;
72
73
  /**
74
   * Constructs an XML processor that can transform an XML document into another
75
   * format based on the XSL file specified as a processing instruction. The
76
   * path must point to the directory where the XSL file is found, which implies
77
   * that they must be in the same directory.
78
   *
79
   * @param processor Next link in the processing chain.
80
   * @param path The path to the XML file content to be processed.
81
   */
82
  public XMLProcessor( final Processor<String> processor, final Path path ) {
83
    super( processor );
84
    setPath( path );
85
  }
86
87
  /**
88
   * Transforms the given XML text into another form (typically Markdown).
89
   *
90
   * @param text The text to transform, can be empty, cannot be null.
91
   *
92
   * @return The transformed text, or empty if text is empty.
93
   */
94
  @Override
95
  public String processLink( final String text ) {
96
    try {
97
      return text.isEmpty() ? text : transform( text );
98
    } catch( Exception e ) {
99
      throw new RuntimeException( e );
100
    }
101
  }
102
103
  /**
104
   * Performs an XSL transformation on the given XML text. The XML text must
105
   * have a processing instruction that points to the XSL template file to use
106
   * for the transformation.
107
   *
108
   * @param text The text to transform.
109
   *
110
   * @return The transformed text.
111
   */
112
  private String transform( final String text ) throws Exception {
113
    // Extract the XML stylesheet processing instruction.
114
    final String template = getXsltFilename( text );
115
    final Path xsl = getXslPath( template );
116
    
117
    // Listen for external file modification events.
118
    getWatchDog().listen( xsl );
119
120
    try(
121
      final StringWriter output = new StringWriter( text.length() );
122
      final StringReader input = new StringReader( text ) ) {
123
      
124
      getTransformer( xsl ).transform(
125
        new StreamSource( input ),
126
        new StreamResult( output )
127
      );
128
      
129
      return output.toString();
130
    }
131
  }
132
133
  /**
134
   * Returns an XSL transformer ready to transform an XML document using the
135
   * XSLT file specified by the given path. If the path is already known then
136
   * this will return the associated transformer.
137
   *
138
   * @param path The path to an XSLT file.
139
   *
140
   * @return A transformer that will transform XML documents using the given
141
   * XSLT file.
142
   *
143
   * @throws TransformerConfigurationException Could not instantiate the
144
   * transformer.
145
   */
146
  private Transformer getTransformer( final Path path )
147
    throws TransformerConfigurationException {
148
    
149
    final TransformerFactory factory = getTransformerFactory();
150
    final Source xslt = new StreamSource( path.toFile() );
151
    return factory.newTransformer( xslt );
152
  }
153
  
154
  private Path getXslPath( final String filename ) {
155
    final Path xmlPath = getPath();
156
    final File xmlDirectory = xmlPath.toFile().getParentFile();
157
    
158
    return Paths.get( xmlDirectory.getPath(), filename );
159
  }
160
161
  /**
162
   * Given XML text, this will use a StAX pull reader to obtain the XML
163
   * stylesheet processing instruction. This will throw a parse exception if the
164
   * href pseudo-attribute filename value cannot be found.
165
   *
166
   * @param xml The XML containing an xml-stylesheet processing instruction.
167
   *
168
   * @return The href pseudo-attribute value.
169
   *
170
   * @throws XMLStreamException Could not parse the XML file.
171
   * @throws ParseException Could not find a non-empty HREF attribute value.
172
   */
173
  private String getXsltFilename( final String xml )
174
    throws XMLStreamException, ParseException {
175
    
176
    String result = "";
177
    
178
    try( final StringReader sr = new StringReader( xml ) ) {
179
      boolean found = false;
180
      int count = 0;
181
      final XMLEventReader reader = createXMLEventReader( sr );
182
183
      // If the processing instruction wasn't found in the first 10 lines,
184
      // fail fast. This should iterate twice through the loop.
185
      while( !found && reader.hasNext() && count++ < 10 ) {
186
        final XMLEvent event = reader.nextEvent();
187
        
188
        if( event.isProcessingInstruction() ) {
189
          final ProcessingInstruction pi = (ProcessingInstruction)event;
190
          final String target = pi.getTarget();
191
          
192
          if( "xml-stylesheet".equalsIgnoreCase( target ) ) {
193
            result = getPseudoAttribute( pi.getData(), "href" );
194
            found = true;
195
          }
196
        }
197
      }
198
      
199
      sr.close();
200
    }
201
    
202
    return result;
203
  }
204
  
205
  private XMLEventReader createXMLEventReader( final Reader reader )
206
    throws XMLStreamException {
207
    return getXMLInputFactory().createXMLEventReader( reader );
208
  }
209
  
210
  private synchronized XMLInputFactory getXMLInputFactory() {
211
    if( this.xmlInputFactory == null ) {
212
      this.xmlInputFactory = createXMLInputFactory();
213
    }
214
    
215
    return this.xmlInputFactory;
216
  }
217
  
218
  private XMLInputFactory createXMLInputFactory() {
219
    return XMLInputFactory.newInstance();
220
  }
221
  
222
  private synchronized TransformerFactory getTransformerFactory() {
223
    if( this.transformerFactory == null ) {
224
      this.transformerFactory = createTransformerFactory();
225
    }
226
    
227
    return this.transformerFactory;
228
  }
229
230
  /**
231
   * Returns a high-performance XSLT 2 transformation engine.
232
   *
233
   * @return An XSL transforming engine.
234
   */
235
  private TransformerFactory createTransformerFactory() {
236
    return new TransformerFactoryImpl();
237
  }
238
  
239
  private void setPath( final Path path ) {
240
    this.path = path;
241
  }
242
  
243
  private Path getPath() {
244
    return this.path;
245
  }
246
  
247
  private Snitch getWatchDog() {
248
    return this.snitch;
249
  }
250
}
1251
D src/main/java/com/scrivenvar/service/Configuration.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.service;
29
30
/**
31
 *
32
 * @author White Magic Software, Ltd.
33
 */
34
public interface Configuration extends Service {
35
36
  public Settings getSettings();
37
38
  public Options getOptions();
39
}
401
M src/main/java/com/scrivenvar/service/Options.java
3535
 * @author White Magic Software, Ltd.
3636
 */
37
public interface Options {
37
public interface Options extends Service {
3838
3939
  public Preferences getState();
A src/main/java/com/scrivenvar/service/Snitch.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.service;
29
30
import java.io.IOException;
31
import java.nio.file.Path;
32
33
/**
34
 * Listens for changes to file system files and directories.
35
 *
36
 * @author White Magic Software, Ltd.
37
 */
38
public interface Snitch extends Service, Runnable {
39
40
  /**
41
   * Listens for changes to the path. If the path specifies a file, then only
42
   * notifications pertaining to that file are sent. Otherwise, change events
43
   * for the directory that contains the file are sent. This method must allow
44
   * for multiple calls to the same file without incurring additional listeners
45
   * or events.
46
   *
47
   * @param file Send notifications when this file changes.
48
   *
49
   * @throws IOException Couldn't create a watcher for the given file.
50
   */
51
  public void listen( Path file ) throws IOException;
52
53
  /**
54
   * Removes the given file from the notifications list.
55
   *
56
   * @param file The file to stop monitoring for any changes.
57
   */
58
  public void ignore( final Path file );
59
60
  /**
61
   * Stop listening for events.
62
   */
63
  public void stop();
64
}
165
M src/main/java/com/scrivenvar/service/impl/DefaultOptions.java
2727
package com.scrivenvar.service.impl;
2828
29
import static com.scrivenvar.Constants.PREFS_OPTIONS;
2930
import static com.scrivenvar.Constants.PREFS_ROOT;
31
import static com.scrivenvar.Constants.PREFS_STATE;
3032
import com.scrivenvar.service.Options;
3133
import java.util.prefs.Preferences;
3234
import static java.util.prefs.Preferences.userRoot;
33
import static com.scrivenvar.Constants.PREFS_STATE;
34
import static com.scrivenvar.Constants.PREFS_OPTIONS;
3535
3636
/**
3737
 * Persistent options user can change at runtime.
3838
 *
3939
 * @author Karl Tauber and White Magic Software, Ltd.
4040
 */
4141
public class DefaultOptions implements Options {
42
4243
  private Preferences preferences;
43
  
44
4445
  public DefaultOptions() {
45
    setPreferences(getRootPreferences().node(PREFS_OPTIONS ) );
46
    setPreferences( getRootPreferences().node( PREFS_OPTIONS ) );
4647
  }
4748
4849
  @Override
4950
  public void put( final String key, final String value ) {
5051
    getPreferences().put( key, value );
5152
  }
52
  
53
5354
  @Override
5455
  public String get( final String key, final String defalutValue ) {
5556
    return getPreferences().get( key, defalutValue );
5657
  }
57
  
58
5859
  private void setPreferences( final Preferences preferences ) {
5960
    this.preferences = preferences;
...
6667
  @Override
6768
  public Preferences getState() {
68
    return getRootPreferences().node(PREFS_STATE );
69
    return getRootPreferences().node( PREFS_STATE );
6970
  }
7071
M src/main/java/com/scrivenvar/service/impl/DefaultSettings.java
3535
import java.net.URISyntaxException;
3636
import java.net.URL;
37
import java.nio.charset.Charset;
3738
import java.util.Iterator;
3839
import java.util.List;
...
128129
129130
    if( url != null ) {
130
      try( final Reader r = new InputStreamReader( url.openStream() ) ) {
131
      try( final Reader r = new InputStreamReader( url.openStream(), getDefaultEncoding() ) ) {
131132
        configuration.setListDelimiterHandler( createListDelimiterHandler() );
132133
        configuration.read( r );
133134
134135
      } catch( IOException e ) {
135136
        throw new ConfigurationException( e );
136137
      }
137138
    }
138139
139140
    return configuration;
141
  }
142
  
143
  protected Charset getDefaultEncoding() {
144
    return Charset.defaultCharset();
140145
  }
141146
  
142147
  protected ListDelimiterHandler createListDelimiterHandler() {
143148
    return new DefaultListDelimiterHandler( VALUE_SEPARATOR );
144149
  }
145150
146151
  private URL getPropertySource() {
147
    return getClass().getResource( getSettingsFilename() );
152
    return DefaultSettings.class.getResource( getSettingsFilename() );
148153
  }
149154
A src/main/java/com/scrivenvar/service/impl/DefaultSnitch.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.service.impl;
29
30
import com.scrivenvar.service.Snitch;
31
import java.io.IOException;
32
import java.nio.file.FileSystem;
33
import java.nio.file.FileSystems;
34
import java.nio.file.Files;
35
import java.nio.file.Path;
36
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
37
import java.nio.file.WatchEvent;
38
import java.nio.file.WatchKey;
39
import java.nio.file.WatchService;
40
import java.util.Collections;
41
import java.util.HashMap;
42
import java.util.HashSet;
43
import java.util.Map;
44
import java.util.Set;
45
46
/**
47
 * Listens for file changes.
48
 *
49
 * @author White Magic Software, Ltd.
50
 */
51
public class DefaultSnitch implements Snitch {
52
53
  /**
54
   * Service for listening to directories for modifications.
55
   */
56
  private WatchService watchService;
57
58
  /**
59
   * Directories being monitored for changes.
60
   */
61
  private Map<WatchKey, Path> keys;
62
63
  /**
64
   * Files that will kick off notification events if modified.
65
   */
66
  private Set<Path> eavesdropped;
67
68
  /**
69
   * Set to true when running; set to false to stop listening.
70
   */
71
  private volatile boolean listening;
72
73
  public DefaultSnitch() {
74
  }
75
76
  @Override
77
  public void stop() {
78
    setListening( false );
79
  }
80
81
  /**
82
   * Adds a listener to the list of files to watch for changes. If the file is
83
   * already in the monitored list, this will return immediately.
84
   *
85
   * @param file Path to a file to watch for changes.
86
   *
87
   * @throws IOException The file could not be monitored.
88
   */
89
  @Override
90
  public void listen( final Path file ) throws IOException {
91
    if( getEavesdropped().add( file ) ) {
92
      final Path dir = toDirectory( file );
93
      final WatchKey key = dir.register( getWatchService(), ENTRY_MODIFY );
94
95
      getWatchMap().put( key, dir );
96
    }
97
  }
98
99
  /**
100
   * Returns the given path to a file (or directory) as a directory. If the
101
   * given path is already a directory, it is returned. Otherwise, this returns
102
   * the directory that contains the file. This will fail if the file is stored
103
   * in the root folder.
104
   *
105
   * @param path The file to return as a directory, which should always be the
106
   * case.
107
   *
108
   * @return The given path as a directory, if a file, otherwise the path
109
   * itself.
110
   */
111
  private Path toDirectory( final Path path ) {
112
    return Files.isDirectory( path )
113
      ? path
114
      : path.toFile().getParentFile().toPath();
115
  }
116
117
  /**
118
   * Stop listening to the given file for change events. This fails silently.
119
   *
120
   * @param file The file to no longer monitor for changes.
121
   */
122
  @Override
123
  public void ignore( final Path file ) {
124
    final Path directory = toDirectory( file );
125
126
    // Remove all occurrences (there should be only one).
127
    getWatchMap().values().removeAll( Collections.singleton( directory ) );
128
129
    // Remove all occurrences (there can be only one).
130
    getEavesdropped().remove( file );
131
  }
132
133
  /**
134
   * Loops until stop is called, or the application is terminated.
135
   */
136
  @Override
137
  @SuppressWarnings( "SleepWhileInLoop" )
138
  public void run() {
139
    setListening( true );
140
141
    while( isListening() ) {
142
      try {
143
        final WatchKey key = getWatchService().take();
144
        final Path path = get( key );
145
146
        // Prevent receiving two separate ENTRY_MODIFY events: file modified
147
        // and timestamp updated. Instead, receive one ENTRY_MODIFY event
148
        // with two counts.
149
        Thread.sleep( 50 );
150
151
        for( final WatchEvent<?> event : key.pollEvents() ) {
152
          final Path changed = path.resolve( (Path)event.context() );
153
154
          if( event.kind() == ENTRY_MODIFY && isListening( changed ) ) {
155
            System.out.println( "RELOAD XSL: " + changed );
156
          }
157
        }
158
159
        if( !key.reset() ) {
160
          ignore( path );
161
        }
162
      } catch( IOException | InterruptedException ex ) {
163
        // Stop eavesdropping.
164
        setListening( false );
165
      }
166
    }
167
  }
168
169
  private boolean isListening( final Path path ) {
170
    return getEavesdropped().contains( path );
171
  }
172
173
  /**
174
   * Returns a path for a given watch key.
175
   *
176
   * @param key The key to lookup its corresponding path.
177
   *
178
   * @return The path for the given key.
179
   */
180
  private Path get( final WatchKey key ) {
181
    return getWatchMap().get( key );
182
  }
183
184
  private synchronized Map<WatchKey, Path> getWatchMap() {
185
    if( this.keys == null ) {
186
      this.keys = createWatchKeys();
187
    }
188
189
    return this.keys;
190
  }
191
192
  protected Map<WatchKey, Path> createWatchKeys() {
193
    return new HashMap<>();
194
  }
195
196
  /**
197
   * Returns a list of files that, when changed, will kick off a notification.
198
   *
199
   * @return A non-null, possibly empty, list of files.
200
   */
201
  private synchronized Set<Path> getEavesdropped() {
202
    if( this.eavesdropped == null ) {
203
      this.eavesdropped = createEavesdropped();
204
    }
205
206
    return this.eavesdropped;
207
  }
208
209
  protected Set<Path> createEavesdropped() {
210
    return new HashSet<>();
211
  }
212
213
  /**
214
   * The existing watch service, or a new instance if null.
215
   *
216
   * @return A valid WatchService instance, never null.
217
   *
218
   * @throws IOException Could not create a new watch service.
219
   */
220
  private synchronized WatchService getWatchService() throws IOException {
221
    if( this.watchService == null ) {
222
      this.watchService = createWatchService();
223
    }
224
225
    return this.watchService;
226
  }
227
228
  protected WatchService createWatchService() throws IOException {
229
    final FileSystem fileSystem = FileSystems.getDefault();
230
    return fileSystem.newWatchService();
231
  }
232
233
  /**
234
   * Answers whether the loop should continue executing.
235
   *
236
   * @return true The internal listening loop should continue listening for file
237
   * modification events.
238
   */
239
  protected boolean isListening() {
240
    return this.listening;
241
  }
242
243
  /**
244
   * Requests the snitch to stop eavesdropping on file changes.
245
   *
246
   * @param listening Use true to indicate the service should stop running.
247
   */
248
  private void setListening( final boolean listening ) {
249
    this.listening = listening;
250
  }
251
}
1252
M src/main/java/com/scrivenvar/test/TestDefinitionPane.java
6767
    test( pane, "c.protagonist", "protagonist" );
6868
69
    System.exit( 0 );
69
    throw new RuntimeException( "Complete" );
7070
  }
7171
M src/main/java/com/scrivenvar/test/TestHarness.java
111111
112112
  protected InputStream asStream( String resource ) {
113
    return getClass().getResourceAsStream( resource );
113
    return TestHarness.class.getResourceAsStream( resource );
114114
  }
115115
}
M src/main/java/com/scrivenvar/test/TestVariableNameProcessor.java
8282
8383
    long duration = System.nanoTime();
84
85
    // TODO: Test replaceEach (with intercoluated variables) and replaceEachRepeatedly
86
    // (without intercoluation).
87
    final String result = testBorAhoCorasick( text, definitions );
88
84
    String result = testBorAhoCorasick( text, definitions );
8985
    duration = System.nanoTime() - duration;
86
    show( result );
87
    System.out.println( elapsed( duration ) );
9088
89
    duration = System.nanoTime();
90
    result = testStringUtils( text, definitions );
91
    duration = System.nanoTime() - duration;
9192
    show( result );
9293
    System.out.println( elapsed( duration ) );
9394
94
    System.exit( 0 );
95
    throw new RuntimeException( "Complete" );
9596
  }
9697
9798
  private void show( final String s ) {
9899
    if( DEBUG ) {
99
      System.out.printf( "%s\n\n", s );
100
      System.out.printf( "%s%n%n", s );
100101
    }
101102
  }
...
199200
    for( final TreeItem<String> child : parent.getChildren() ) {
200201
      if( child.isLeaf() ) {
201
        final String key = asDefinition( ((VariableTreeItem<String>)child).toPath() );
202
        final VariableTreeItem<String> item;
203
204
        if( child instanceof VariableTreeItem ) {
205
          item = ((VariableTreeItem<String>)child);
206
        } else {
207
          throw new IllegalArgumentException(
208
            "Child must be subclass of VariableTreeItem: " + child );
209
        }
210
211
        final String key = asDefinition( item.toPath() );
202212
        final String value = child.getValue();
203213
A src/main/resources/META-INF/services/com.scrivenvar.service.Snitch
1
1
com.scrivenvar.service.impl.DefaultSnitch