Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M _config.yaml
11
---
2
River: "Door"
3
Ocean: "Floor"
2
application:
3
  title: "Scrivenvar"
44
M build.gradle
22
  id 'application'
33
  id 'org.openjfx.javafxplugin' version '0.0.8'
4
  id 'com.palantir.git-version' version '0.12.3'
45
}
56
...
6364
6465
sourceCompatibility = JavaVersion.VERSION_11
65
version = '1.5.0'
6666
applicationName = 'scrivenvar'
67
mainClassName = 'com.scrivenvar.Main'
68
def launcherClassName = 'com.scrivenvar.Launcher'
67
version gitVersion()
68
mainClassName = "com.${applicationName}.Main"
69
def launcherClassName = "com.${applicationName}.Launcher"
70
71
def propertiesFile = new File("src/main/resources/com/$applicationName/app.properties")
72
propertiesFile.write("application.version=${version}")
6973
7074
jar {
...
8185
  }
8286
83
  archiveFileName = 'scrivenvar.jar'
87
  archiveFileName = "${applicationName}.jar"
8488
8589
  exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA'
M images/screenshot.png
Binary file
M src/main/java/com/scrivenvar/Constants.java
5555
5656
  // Bootstrapping...
57
  public static final String SETTINGS_NAME = "/com/scrivenvar/settings" +
58
      ".properties";
57
  public static final String SETTINGS_NAME =
58
      "/com/scrivenvar/settings.properties";
5959
6060
  public static final String APP_TITLE = get( "application.title" );
6161
  public static final String APP_BUNDLE_NAME = get( "application.messages" );
6262
6363
  // Prevent double events when updating files on Linux (save and timestamp).
6464
  public static final int APP_WATCHDOG_TIMEOUT = get(
65
      "application.watchdog.timeout",
66
      100 );
65
      "application.watchdog.timeout", 100 );
6766
6867
  public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" );
6968
  public static final String STYLESHEET_MARKDOWN = get(
7069
      "file.stylesheet.markdown" );
71
  public static final String STYLESHEET_PREVIEW = get( "file.stylesheet" +
72
                                                           ".preview" );
70
  public static final String STYLESHEET_PREVIEW = get(
71
      "file.stylesheet.preview" );
7372
7473
  public static final String FILE_LOGO_16 = get( "file.logo.16" );
M src/main/java/com/scrivenvar/FileEditorTab.java
3838
import javafx.beans.value.ChangeListener;
3939
import javafx.beans.value.ObservableValue;
40
import javafx.scene.Node;
41
import javafx.scene.Scene;
42
import javafx.scene.control.Tab;
43
import javafx.scene.control.Tooltip;
44
import javafx.scene.text.Text;
45
import javafx.stage.Window;
46
import org.fxmisc.richtext.StyleClassedTextArea;
47
import org.fxmisc.richtext.model.TwoDimensional.Position;
48
import org.fxmisc.undo.UndoManager;
49
import org.mozilla.universalchardet.UniversalDetector;
50
51
import java.io.File;
52
import java.io.IOException;
53
import java.nio.charset.Charset;
54
import java.nio.file.Files;
55
import java.nio.file.Path;
56
57
import static com.scrivenvar.Messages.get;
58
import static java.nio.charset.StandardCharsets.UTF_8;
59
import static java.util.Locale.ENGLISH;
60
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
61
62
/**
63
 * Editor for a single file.
64
 *
65
 * @author Karl Tauber and White Magic Software, Ltd.
66
 */
67
public final class FileEditorTab extends Tab {
68
69
  private final Notifier mNotifier = Services.load( Notifier.class );
70
  private final EditorPane mEditorPane = new MarkdownEditorPane();
71
72
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
73
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
74
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
75
76
  /**
77
   * Character encoding used by the file (or default encoding if none found).
78
   */
79
  private Charset mEncoding = UTF_8;
80
81
  /**
82
   * File to load into the editor.
83
   */
84
  private Path mPath;
85
86
  public FileEditorTab( final Path path ) {
87
    setPath( path );
88
89
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
90
91
    setOnSelectionChanged( e -> {
92
      if( isSelected() ) {
93
        Platform.runLater( this::activated );
94
      }
95
    } );
96
  }
97
98
  private void updateTab() {
99
    setText( getTabTitle() );
100
    setGraphic( getModifiedMark() );
101
    setTooltip( getTabTooltip() );
102
  }
103
104
  /**
105
   * Returns the base filename (without the directory names).
106
   *
107
   * @return The untitled text if the path hasn't been set.
108
   */
109
  private String getTabTitle() {
110
    return getPath().getFileName().toString();
111
  }
112
113
  /**
114
   * Returns the full filename represented by the path.
115
   *
116
   * @return The untitled text if the path hasn't been set.
117
   */
118
  private Tooltip getTabTooltip() {
119
    final Path filePath = getPath();
120
    return new Tooltip( filePath == null ? "" : filePath.toString() );
121
  }
122
123
  /**
124
   * Returns a marker to indicate whether the file has been modified.
125
   *
126
   * @return "*" when the file has changed; otherwise null.
127
   */
128
  private Text getModifiedMark() {
129
    return isModified() ? new Text( "*" ) : null;
130
  }
131
132
  /**
133
   * Called when the user switches tab.
134
   */
135
  private void activated() {
136
    // Tab is closed or no longer active.
137
    if( getTabPane() == null || !isSelected() ) {
138
      return;
139
    }
140
141
    // Switch to the tab without loading if the contents are already in memory.
142
    if( getContent() != null ) {
143
      getEditorPane().requestFocus();
144
      return;
145
    }
146
147
    // Load the text and update the preview before the undo manager.
148
    load();
149
150
    // Track undo requests -- can only be called *after* load.
151
    initUndoManager();
152
    initLayout();
153
    initFocus();
154
  }
155
156
  private void initLayout() {
157
    setContent( getScrollPane() );
158
  }
159
160
  private Node getScrollPane() {
161
    return getEditorPane().getScrollPane();
162
  }
163
164
  private void initFocus() {
165
    getEditorPane().requestFocus();
166
  }
167
168
  private void initUndoManager() {
169
    final UndoManager<?> undoManager = getUndoManager();
170
    undoManager.forgetHistory();
171
172
    // Bind the editor undo manager to the properties.
173
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
174
    canUndo.bind( undoManager.undoAvailableProperty() );
175
    canRedo.bind( undoManager.redoAvailableProperty() );
176
  }
177
178
  /**
179
   * Searches from the caret position forward for the given string.
180
   *
181
   * @param needle The text string to match.
182
   */
183
  public void searchNext( final String needle ) {
184
    final String haystack = getEditorText();
185
    int index = haystack.indexOf( needle, getCaretPosition() );
186
187
    // Wrap around.
188
    if( index == -1 ) {
189
      index = haystack.indexOf( needle );
190
    }
191
192
    if( index >= 0 ) {
193
      setCaretPosition( index );
194
      getEditor().selectRange( index, index + needle.length() );
195
    }
196
  }
197
198
  /**
199
   * Returns the index into the text where the caret blinks happily away.
200
   *
201
   * @return A number from 0 to the editor's document text length.
202
   */
203
  public int getCaretPosition() {
204
    return getEditor().getCaretPosition();
205
  }
206
207
  /**
208
   * Moves the caret to a given offset.
209
   *
210
   * @param offset The new caret offset.
211
   */
212
  private void setCaretPosition( final int offset ) {
213
    getEditor().moveTo( offset );
214
    getEditor().requestFollowCaret();
215
  }
216
217
  /**
218
   * Returns the caret's current row and column position.
219
   *
220
   * @return The caret's offset into the document.
221
   */
222
  public Position getCaretOffset() {
223
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
224
  }
225
226
  /**
227
   * Allows observers to synchronize caret position changes.
228
   *
229
   * @return An observable caret property value.
230
   */
231
  public final ObservableValue<Integer> caretPositionProperty() {
232
    return getEditor().caretPositionProperty();
233
  }
234
235
  /**
236
   * Returns the text area associated with this tab.
237
   *
238
   * @return A text editor.
239
   */
240
  private StyleClassedTextArea getEditor() {
241
    return getEditorPane().getEditor();
242
  }
243
244
  /**
245
   * Returns true if the given path exactly matches this tab's path.
246
   *
247
   * @param check The path to compare against.
248
   * @return true The paths are the same.
249
   */
250
  public boolean isPath( final Path check ) {
251
    final Path filePath = getPath();
252
253
    return filePath != null && filePath.equals( check );
254
  }
255
256
  /**
257
   * Reads the entire file contents from the path associated with this tab.
258
   */
259
  private void load() {
260
    final Path path = getPath();
261
    final File file = path.toFile();
262
263
    try {
264
      if( file.exists() ) {
265
        if( file.canWrite() && file.canRead() ) {
266
          final EditorPane pane = getEditorPane();
267
          pane.setText( asString( Files.readAllBytes( path ) ) );
268
          pane.scrollToTop();
269
        }
270
        else {
271
          final String msg = get(
272
              "FileEditor.loadFailed.message",
273
              file.toString(),
274
              get( "FileEditor.loadFailed.reason.permissions" )
275
          );
276
          getNotifier().notify( msg );
277
        }
278
      }
279
    } catch( final Exception ex ) {
280
      getNotifier().notify( ex );
281
    }
282
  }
283
284
  /**
285
   * Saves the entire file contents from the path associated with this tab.
286
   *
287
   * @return true The file has been saved.
288
   */
289
  public boolean save() {
290
    try {
291
      final EditorPane editor = getEditorPane();
292
      Files.write( getPath(), asBytes( editor.getText() ) );
293
      editor.getUndoManager().mark();
294
      return true;
295
    } catch( final Exception ex ) {
296
      return alert(
297
          "FileEditor.saveFailed.title",
298
          "FileEditor.saveFailed.message",
299
          ex
300
      );
301
    }
302
  }
303
304
  /**
305
   * Creates an alert dialog and waits for it to close.
306
   *
307
   * @param titleKey   Resource bundle key for the alert dialog title.
308
   * @param messageKey Resource bundle key for the alert dialog message.
309
   * @param e          The unexpected happening.
310
   * @return false
311
   */
312
  @SuppressWarnings("SameParameterValue")
313
  private boolean alert(
314
      final String titleKey, final String messageKey, final Exception e ) {
315
    final Notifier service = getNotifier();
316
    final Path filePath = getPath();
317
318
    final Notification message = service.createNotification(
319
        get( titleKey ),
320
        get( messageKey ),
321
        filePath == null ? "" : filePath,
322
        e.getMessage()
323
    );
324
325
    try {
326
      service.createError( getWindow(), message ).showAndWait();
327
    } catch( final Exception ex ) {
328
      getNotifier().notify( ex );
329
    }
330
331
    return false;
332
  }
333
334
  private Window getWindow() {
335
    final Scene scene = getEditorPane().getScene();
336
337
    if( scene == null ) {
338
      throw new UnsupportedOperationException( "No scene window available" );
339
    }
340
341
    return scene.getWindow();
342
  }
343
344
  /**
345
   * Returns a best guess at the file encoding. If the encoding could not be
346
   * detected, this will return the default charset for the JVM.
347
   *
348
   * @param bytes The bytes to perform character encoding detection.
349
   * @return The character encoding.
350
   */
351
  private Charset detectEncoding( final byte[] bytes ) {
352
    final UniversalDetector detector = new UniversalDetector( null );
353
    detector.handleData( bytes, 0, bytes.length );
354
    detector.dataEnd();
355
356
    final String charset = detector.getDetectedCharset();
357
    final Charset charEncoding = charset == null
358
        ? Charset.defaultCharset()
359
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
360
361
    detector.reset();
362
363
    return charEncoding;
364
  }
365
366
  /**
367
   * Converts the given string to an array of bytes using the encoding that was
368
   * originally detected (if any) and associated with this file.
369
   *
370
   * @param text The text to convert into the original file encoding.
371
   * @return A series of bytes ready for writing to a file.
372
   */
373
  private byte[] asBytes( final String text ) {
374
    return text.getBytes( getEncoding() );
375
  }
376
377
  /**
378
   * Converts the given bytes into a Java String. This will call setEncoding
379
   * with the encoding detected by the CharsetDetector.
380
   *
381
   * @param text The text of unknown character encoding.
382
   * @return The text, in its auto-detected encoding, as a String.
383
   */
384
  private String asString( final byte[] text ) {
385
    setEncoding( detectEncoding( text ) );
386
    return new String( text, getEncoding() );
387
  }
388
389
  /**
390
   * Returns the path to the file being edited in this tab.
391
   *
392
   * @return A non-null instance.
393
   */
394
  public Path getPath() {
395
    return mPath;
396
  }
397
398
  /**
399
   * Sets the path to a file for editing and then updates the tab with the
400
   * file contents.
401
   *
402
   * @param path A non-null instance.
403
   */
404
  public void setPath( final Path path ) {
405
    assert path != null;
406
407
    mPath = path;
408
409
    updateTab();
410
  }
411
412
  public boolean isModified() {
413
    return mModified.get();
414
  }
415
416
  ReadOnlyBooleanProperty modifiedProperty() {
417
    return mModified.getReadOnlyProperty();
418
  }
419
420
  BooleanProperty canUndoProperty() {
421
    return this.canUndo;
422
  }
423
424
  BooleanProperty canRedoProperty() {
425
    return this.canRedo;
426
  }
427
428
  private UndoManager<?> getUndoManager() {
429
    return getEditorPane().getUndoManager();
430
  }
431
432
  /**
433
   * Forwards to the editor pane's listeners for text change events.
434
   *
435
   * @param listener The listener to notify when the text changes.
436
   */
437
  public void addTextChangeListener( final ChangeListener<String> listener ) {
438
    getEditorPane().addTextChangeListener( listener );
439
  }
440
441
  /**
442
   * Forwards to the editor pane's listeners for caret paragraph change events.
443
   *
444
   * @param listener The listener to notify when the caret changes paragraphs.
445
   */
446
  public void addCaretParagraphListener(
447
      final ChangeListener<Integer> listener ) {
448
    getEditorPane().addCaretParagraphListener( listener );
40
import javafx.event.Event;
41
import javafx.event.EventHandler;
42
import javafx.event.EventType;
43
import javafx.scene.Node;
44
import javafx.scene.Scene;
45
import javafx.scene.control.Tab;
46
import javafx.scene.control.Tooltip;
47
import javafx.scene.text.Text;
48
import javafx.stage.Window;
49
import org.fxmisc.richtext.StyleClassedTextArea;
50
import org.fxmisc.richtext.model.TwoDimensional.Position;
51
import org.fxmisc.undo.UndoManager;
52
import org.mozilla.universalchardet.UniversalDetector;
53
54
import java.io.File;
55
import java.nio.charset.Charset;
56
import java.nio.file.Files;
57
import java.nio.file.Path;
58
59
import static com.scrivenvar.Messages.get;
60
import static java.nio.charset.StandardCharsets.UTF_8;
61
import static java.util.Locale.ENGLISH;
62
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
63
64
/**
65
 * Editor for a single file.
66
 *
67
 * @author Karl Tauber and White Magic Software, Ltd.
68
 */
69
public final class FileEditorTab extends Tab {
70
71
  private final Notifier mNotifier = Services.load( Notifier.class );
72
  private final EditorPane mEditorPane = new MarkdownEditorPane();
73
74
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
75
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
76
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
77
78
  /**
79
   * Character encoding used by the file (or default encoding if none found).
80
   */
81
  private Charset mEncoding = UTF_8;
82
83
  /**
84
   * File to load into the editor.
85
   */
86
  private Path mPath;
87
88
  public FileEditorTab( final Path path ) {
89
    setPath( path );
90
91
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
92
93
    setOnSelectionChanged( e -> {
94
      if( isSelected() ) {
95
        Platform.runLater( this::activated );
96
      }
97
    } );
98
  }
99
100
  private void updateTab() {
101
    setText( getTabTitle() );
102
    setGraphic( getModifiedMark() );
103
    setTooltip( getTabTooltip() );
104
  }
105
106
  /**
107
   * Returns the base filename (without the directory names).
108
   *
109
   * @return The untitled text if the path hasn't been set.
110
   */
111
  private String getTabTitle() {
112
    return getPath().getFileName().toString();
113
  }
114
115
  /**
116
   * Returns the full filename represented by the path.
117
   *
118
   * @return The untitled text if the path hasn't been set.
119
   */
120
  private Tooltip getTabTooltip() {
121
    final Path filePath = getPath();
122
    return new Tooltip( filePath == null ? "" : filePath.toString() );
123
  }
124
125
  /**
126
   * Returns a marker to indicate whether the file has been modified.
127
   *
128
   * @return "*" when the file has changed; otherwise null.
129
   */
130
  private Text getModifiedMark() {
131
    return isModified() ? new Text( "*" ) : null;
132
  }
133
134
  /**
135
   * Called when the user switches tab.
136
   */
137
  private void activated() {
138
    // Tab is closed or no longer active.
139
    if( getTabPane() == null || !isSelected() ) {
140
      return;
141
    }
142
143
    // Switch to the tab without loading if the contents are already in memory.
144
    if( getContent() != null ) {
145
      getEditorPane().requestFocus();
146
      return;
147
    }
148
149
    // Load the text and update the preview before the undo manager.
150
    load();
151
152
    // Track undo requests -- can only be called *after* load.
153
    initUndoManager();
154
    initLayout();
155
    initFocus();
156
  }
157
158
  private void initLayout() {
159
    setContent( getScrollPane() );
160
  }
161
162
  private Node getScrollPane() {
163
    return getEditorPane().getScrollPane();
164
  }
165
166
  private void initFocus() {
167
    getEditorPane().requestFocus();
168
  }
169
170
  private void initUndoManager() {
171
    final UndoManager<?> undoManager = getUndoManager();
172
    undoManager.forgetHistory();
173
174
    // Bind the editor undo manager to the properties.
175
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
176
    canUndo.bind( undoManager.undoAvailableProperty() );
177
    canRedo.bind( undoManager.redoAvailableProperty() );
178
  }
179
180
  /**
181
   * Searches from the caret position forward for the given string.
182
   *
183
   * @param needle The text string to match.
184
   */
185
  public void searchNext( final String needle ) {
186
    final String haystack = getEditorText();
187
    int index = haystack.indexOf( needle, getCaretPosition() );
188
189
    // Wrap around.
190
    if( index == -1 ) {
191
      index = haystack.indexOf( needle );
192
    }
193
194
    if( index >= 0 ) {
195
      setCaretPosition( index );
196
      getEditor().selectRange( index, index + needle.length() );
197
    }
198
  }
199
200
  /**
201
   * Returns the index into the text where the caret blinks happily away.
202
   *
203
   * @return A number from 0 to the editor's document text length.
204
   */
205
  public int getCaretPosition() {
206
    return getEditor().getCaretPosition();
207
  }
208
209
  /**
210
   * Moves the caret to a given offset.
211
   *
212
   * @param offset The new caret offset.
213
   */
214
  private void setCaretPosition( final int offset ) {
215
    getEditor().moveTo( offset );
216
    getEditor().requestFollowCaret();
217
  }
218
219
  /**
220
   * Returns the caret's current row and column position.
221
   *
222
   * @return The caret's offset into the document.
223
   */
224
  public Position getCaretOffset() {
225
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
226
  }
227
228
  /**
229
   * Allows observers to synchronize caret position changes.
230
   *
231
   * @return An observable caret property value.
232
   */
233
  public final ObservableValue<Integer> caretPositionProperty() {
234
    return getEditor().caretPositionProperty();
235
  }
236
237
  /**
238
   * Returns the text area associated with this tab.
239
   *
240
   * @return A text editor.
241
   */
242
  private StyleClassedTextArea getEditor() {
243
    return getEditorPane().getEditor();
244
  }
245
246
  /**
247
   * Returns true if the given path exactly matches this tab's path.
248
   *
249
   * @param check The path to compare against.
250
   * @return true The paths are the same.
251
   */
252
  public boolean isPath( final Path check ) {
253
    final Path filePath = getPath();
254
255
    return filePath != null && filePath.equals( check );
256
  }
257
258
  /**
259
   * Reads the entire file contents from the path associated with this tab.
260
   */
261
  private void load() {
262
    final Path path = getPath();
263
    final File file = path.toFile();
264
265
    try {
266
      if( file.exists() ) {
267
        if( file.canWrite() && file.canRead() ) {
268
          final EditorPane pane = getEditorPane();
269
          pane.setText( asString( Files.readAllBytes( path ) ) );
270
          pane.scrollToTop();
271
        }
272
        else {
273
          final String msg = get(
274
              "FileEditor.loadFailed.message",
275
              file.toString(),
276
              get( "FileEditor.loadFailed.reason.permissions" )
277
          );
278
          getNotifier().notify( msg );
279
        }
280
      }
281
    } catch( final Exception ex ) {
282
      getNotifier().notify( ex );
283
    }
284
  }
285
286
  /**
287
   * Saves the entire file contents from the path associated with this tab.
288
   *
289
   * @return true The file has been saved.
290
   */
291
  public boolean save() {
292
    try {
293
      final EditorPane editor = getEditorPane();
294
      Files.write( getPath(), asBytes( editor.getText() ) );
295
      editor.getUndoManager().mark();
296
      return true;
297
    } catch( final Exception ex ) {
298
      return alert(
299
          "FileEditor.saveFailed.title",
300
          "FileEditor.saveFailed.message",
301
          ex
302
      );
303
    }
304
  }
305
306
  /**
307
   * Creates an alert dialog and waits for it to close.
308
   *
309
   * @param titleKey   Resource bundle key for the alert dialog title.
310
   * @param messageKey Resource bundle key for the alert dialog message.
311
   * @param e          The unexpected happening.
312
   * @return false
313
   */
314
  @SuppressWarnings("SameParameterValue")
315
  private boolean alert(
316
      final String titleKey, final String messageKey, final Exception e ) {
317
    final Notifier service = getNotifier();
318
    final Path filePath = getPath();
319
320
    final Notification message = service.createNotification(
321
        get( titleKey ),
322
        get( messageKey ),
323
        filePath == null ? "" : filePath,
324
        e.getMessage()
325
    );
326
327
    try {
328
      service.createError( getWindow(), message ).showAndWait();
329
    } catch( final Exception ex ) {
330
      getNotifier().notify( ex );
331
    }
332
333
    return false;
334
  }
335
336
  private Window getWindow() {
337
    final Scene scene = getEditorPane().getScene();
338
339
    if( scene == null ) {
340
      throw new UnsupportedOperationException( "No scene window available" );
341
    }
342
343
    return scene.getWindow();
344
  }
345
346
  /**
347
   * Returns a best guess at the file encoding. If the encoding could not be
348
   * detected, this will return the default charset for the JVM.
349
   *
350
   * @param bytes The bytes to perform character encoding detection.
351
   * @return The character encoding.
352
   */
353
  private Charset detectEncoding( final byte[] bytes ) {
354
    final UniversalDetector detector = new UniversalDetector( null );
355
    detector.handleData( bytes, 0, bytes.length );
356
    detector.dataEnd();
357
358
    final String charset = detector.getDetectedCharset();
359
    final Charset charEncoding = charset == null
360
        ? Charset.defaultCharset()
361
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
362
363
    detector.reset();
364
365
    return charEncoding;
366
  }
367
368
  /**
369
   * Converts the given string to an array of bytes using the encoding that was
370
   * originally detected (if any) and associated with this file.
371
   *
372
   * @param text The text to convert into the original file encoding.
373
   * @return A series of bytes ready for writing to a file.
374
   */
375
  private byte[] asBytes( final String text ) {
376
    return text.getBytes( getEncoding() );
377
  }
378
379
  /**
380
   * Converts the given bytes into a Java String. This will call setEncoding
381
   * with the encoding detected by the CharsetDetector.
382
   *
383
   * @param text The text of unknown character encoding.
384
   * @return The text, in its auto-detected encoding, as a String.
385
   */
386
  private String asString( final byte[] text ) {
387
    setEncoding( detectEncoding( text ) );
388
    return new String( text, getEncoding() );
389
  }
390
391
  /**
392
   * Returns the path to the file being edited in this tab.
393
   *
394
   * @return A non-null instance.
395
   */
396
  public Path getPath() {
397
    return mPath;
398
  }
399
400
  /**
401
   * Sets the path to a file for editing and then updates the tab with the
402
   * file contents.
403
   *
404
   * @param path A non-null instance.
405
   */
406
  public void setPath( final Path path ) {
407
    assert path != null;
408
409
    mPath = path;
410
411
    updateTab();
412
  }
413
414
  public boolean isModified() {
415
    return mModified.get();
416
  }
417
418
  ReadOnlyBooleanProperty modifiedProperty() {
419
    return mModified.getReadOnlyProperty();
420
  }
421
422
  BooleanProperty canUndoProperty() {
423
    return this.canUndo;
424
  }
425
426
  BooleanProperty canRedoProperty() {
427
    return this.canRedo;
428
  }
429
430
  private UndoManager<?> getUndoManager() {
431
    return getEditorPane().getUndoManager();
432
  }
433
434
  /**
435
   * Forwards to the editor pane's listeners for text change events.
436
   *
437
   * @param listener The listener to notify when the text changes.
438
   */
439
  public void addTextChangeListener( final ChangeListener<String> listener ) {
440
    getEditorPane().addTextChangeListener( listener );
441
  }
442
443
  /**
444
   * Forwards to the editor pane's listeners for caret paragraph change events.
445
   *
446
   * @param listener The listener to notify when the caret changes paragraphs.
447
   */
448
  public void addCaretParagraphListener(
449
      final ChangeListener<Integer> listener ) {
450
    getEditorPane().addCaretParagraphListener( listener );
451
  }
452
453
  public <T extends Event> void addEventFilter(
454
      final EventType<T> eventType,
455
      final EventHandler<? super T> eventFilter ) {
456
    getEditorPane().getEditor().addEventFilter( eventType, eventFilter );
449457
  }
450458
M src/main/java/com/scrivenvar/FileEditorTabPane.java
5151
import javafx.stage.FileChooser.ExtensionFilter;
5252
import javafx.stage.Window;
53
import org.fxmisc.richtext.StyledTextArea;
5453
5554
import java.io.File;
...
7473
public final class FileEditorTabPane extends TabPane {
7574
76
  private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose" +
77
      ".filter";
75
  private final static String FILTER_EXTENSION_TITLES =
76
      "Dialog.file.choose.filter";
7877
7978
  private final Options mOptions = Services.load( Options.class );
...
149148
    // a notification is kicked off.
150149
    getSelectionModel().selectedItemProperty().addListener( listener );
151
  }
152
153
  /**
154
   * Allows clients to manipulate the editor content directly.
155
   *
156
   * @return The text area for the active file editor.
157
   */
158
  public StyledTextArea getEditor() {
159
    return getActiveFileEditor().getEditorPane().getEditor();
160150
  }
161151
M src/main/java/com/scrivenvar/Launcher.java
2828
package com.scrivenvar;
2929
30
import java.io.IOException;
31
import java.io.InputStream;
32
import java.util.Calendar;
33
import java.util.Properties;
34
35
import static java.lang.String.format;
36
3037
/**
3138
 * Launches the application using the {@link Main} class.
...
4249
   * @param args Command-line arguments.
4350
   */
44
  public static void main( final String[] args ) {
51
  public static void main( final String[] args ) throws IOException {
52
    showAppInfo();
4553
    Main.main( args );
54
  }
55
56
  private static void showAppInfo() throws IOException {
57
    out( format( "%s version %s%n", getTitle(), getVersion() ) );
58
    out( format( "Copyright %s by White Magic Software, Ltd.%n", getYear() ) );
59
    out( "Portions copyright 2020 Karl Tauber.\n" );
60
  }
61
62
  private static void out( final String s ) {
63
    System.out.print( s );
64
  }
65
66
  private static String getTitle() throws IOException {
67
    final Properties properties = loadProperties( "messages.properties" );
68
    return properties.getProperty( "Main.title" );
69
  }
70
71
  private static String getVersion() throws IOException {
72
    final Properties properties = loadProperties( "app.properties" );
73
    return properties.getProperty( "application.version" );
74
  }
75
76
  private static String getYear() {
77
    return Integer.toString( Calendar.getInstance().get( Calendar.YEAR ) );
78
  }
79
80
  @SuppressWarnings("SameParameterValue")
81
  private static Properties loadProperties( final String resource )
82
      throws IOException {
83
    final Properties properties = new Properties();
84
    properties.load( getResourceAsStream( getResourceName( resource ) ) );
85
    return properties;
86
  }
87
88
  private static String getResourceName( final String resource ) {
89
    return format( "%s/%s", getPackagePath(), resource );
90
  }
91
92
  private static String getPackagePath() {
93
    return Launcher.class.getPackageName().replace( '.', '/' );
94
  }
95
96
  private static InputStream getResourceAsStream( final String resource ) {
97
    return Launcher.class.getClassLoader().getResourceAsStream( resource );
4698
  }
4799
}
M src/main/java/com/scrivenvar/MainWindow.java
8585
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
8686
import static javafx.event.Event.fireEvent;
87
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
88
89
/**
90
 * Main window containing a tab pane in the center for file editors.
91
 *
92
 * @author Karl Tauber and White Magic Software, Ltd.
93
 */
94
public class MainWindow implements Observer {
95
96
  private final Options mOptions = Services.load( Options.class );
97
  private final Snitch mSnitch = Services.load( Snitch.class );
98
  private final Settings mSettings = Services.load( Settings.class );
99
  private final Notifier mNotifier = Services.load( Notifier.class );
100
101
  private final Scene mScene;
102
  private final StatusBar mStatusBar;
103
  private final Text mLineNumberText;
104
  private final TextField mFindTextField;
105
106
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
107
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
108
  private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane();
109
  private FileEditorTabPane fileEditorPane;
110
111
  /**
112
   * Prevents re-instantiation of processing classes.
113
   */
114
  private final Map<FileEditorTab, Processor<String>> mProcessors =
115
      new HashMap<>();
116
117
  private final Map<String, String> mResolvedMap =
118
      new HashMap<>( DEFAULT_MAP_SIZE );
119
120
  /**
121
   * Listens on the definition pane for double-click events.
122
   */
123
  private VariableNameInjector variableNameInjector;
124
125
  /**
126
   * Called when the definition data is changed.
127
   */
128
  final EventHandler<TreeItem.TreeModificationEvent<Event>> mHandler =
129
      event -> {
130
        exportDefinitions( getDefinitionPath() );
131
        interpolateResolvedMap();
132
        refreshActiveTab();
133
      };
134
135
  public MainWindow() {
136
    mStatusBar = createStatusBar();
137
    mLineNumberText = createLineNumberText();
138
    mFindTextField = createFindTextField();
139
    mScene = createScene();
140
141
    initLayout();
142
    initFindInput();
143
    initSnitch();
144
    initDefinitionListener();
145
    initTabAddedListener();
146
    initTabChangedListener();
147
    initPreferences();
148
  }
149
150
  /**
151
   * Watch for changes to external files. In particular, this awaits
152
   * modifications to any XSL files associated with XML files being edited. When
153
   * an XSL file is modified (external to the application), the snitch's ears
154
   * perk up and the file is reloaded. This keeps the XSL transformation up to
155
   * date with what's on the file system.
156
   */
157
  private void initSnitch() {
158
    getSnitch().addObserver( this );
159
  }
160
161
  /**
162
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
163
   * presses.
164
   */
165
  private void initFindInput() {
166
    final TextField input = getFindTextField();
167
168
    input.setOnKeyPressed( ( KeyEvent event ) -> {
169
      switch( event.getCode() ) {
170
        case F3:
171
        case ENTER:
172
          findNext();
173
          break;
174
        case F:
175
          if( !event.isControlDown() ) {
176
            break;
177
          }
178
        case ESCAPE:
179
          getStatusBar().setGraphic( null );
180
          getActiveFileEditor().getEditorPane().requestFocus();
181
          break;
182
      }
183
    } );
184
185
    // Remove when the input field loses focus.
186
    input.focusedProperty().addListener(
187
        (
188
            final ObservableValue<? extends Boolean> focused,
189
            final Boolean oFocus,
190
            final Boolean nFocus ) -> {
191
          if( !nFocus ) {
192
            getStatusBar().setGraphic( null );
193
          }
194
        }
195
    );
196
  }
197
198
  /**
199
   * Listen for {@link FileEditorTabPane} to receive open definition file event.
200
   */
201
  private void initDefinitionListener() {
202
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
203
        ( final ObservableValue<? extends Path> file,
204
          final Path oldPath, final Path newPath ) -> {
205
          // Indirectly refresh the resolved map.
206
          resetProcessors();
207
208
          openDefinitions( newPath );
209
210
          // Will create new processors and therefore a new resolved map.
211
          refreshActiveTab();
212
        }
213
    );
214
  }
215
216
  /**
217
   * When tabs are added, hook the various change listeners onto the new tab so
218
   * that the preview pane refreshes as necessary.
219
   */
220
  private void initTabAddedListener() {
221
    final FileEditorTabPane editorPane = getFileEditorPane();
222
223
    // Make sure the text processor kicks off when new files are opened.
224
    final ObservableList<Tab> tabs = editorPane.getTabs();
225
226
    // Update the preview pane on tab changes.
227
    tabs.addListener(
228
        ( final Change<? extends Tab> change ) -> {
229
          while( change.next() ) {
230
            if( change.wasAdded() ) {
231
              // Multiple tabs can be added simultaneously.
232
              for( final Tab newTab : change.getAddedSubList() ) {
233
                final FileEditorTab tab = (FileEditorTab) newTab;
234
235
                initTextChangeListener( tab );
236
                initCaretParagraphListener( tab );
237
                initKeyboardEventListeners( tab );
238
//              initSyntaxListener( tab );
239
              }
240
            }
241
          }
242
        }
243
    );
244
  }
245
246
  /**
247
   * Reloads the preferences from the previous session.
248
   */
249
  private void initPreferences() {
250
    restoreDefinitionPane();
251
    getFileEditorPane().restorePreferences();
252
  }
253
254
  /**
255
   * Listen for new tab selection events.
256
   */
257
  private void initTabChangedListener() {
258
    final FileEditorTabPane editorPane = getFileEditorPane();
259
260
    // Update the preview pane changing tabs.
261
    editorPane.addTabSelectionListener(
262
        ( ObservableValue<? extends Tab> tabPane,
263
          final Tab oldTab, final Tab newTab ) -> {
264
          updateVariableNameInjector();
265
266
          // If there was no old tab, then this is a first time load, which
267
          // can be ignored.
268
          if( oldTab != null ) {
269
            if( newTab == null ) {
270
              closeRemainingTab();
271
            }
272
            else {
273
              // Update the preview with the edited text.
274
              refreshSelectedTab( (FileEditorTab) newTab );
275
            }
276
          }
277
        }
278
    );
279
  }
280
281
  /**
282
   * Ensure that the keyboard events are received when a new tab is added
283
   * to the user interface.
284
   *
285
   * @param tab The tab that can trigger keyboard events, such as control+space.
286
   */
287
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
288
    final VariableNameInjector vin = getVariableNameInjector();
289
    vin.initKeyboardEventListeners( tab );
290
  }
291
292
  private void initTextChangeListener( final FileEditorTab tab ) {
293
    tab.addTextChangeListener(
294
        ( ObservableValue<? extends String> editor,
295
          final String oldValue, final String newValue ) ->
296
            refreshSelectedTab( tab )
297
    );
298
  }
299
300
  private void initCaretParagraphListener( final FileEditorTab tab ) {
301
    tab.addCaretParagraphListener(
302
        ( ObservableValue<? extends Integer> editor,
303
          final Integer oldValue, final Integer newValue ) ->
304
            refreshSelectedTab( tab )
305
    );
306
  }
307
308
  private void updateVariableNameInjector() {
309
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
310
  }
311
312
  private void setVariableNameInjector( final VariableNameInjector injector ) {
313
    this.variableNameInjector = injector;
314
  }
315
316
  private synchronized VariableNameInjector getVariableNameInjector() {
317
    if( this.variableNameInjector == null ) {
318
      final VariableNameInjector vin = createVariableNameInjector();
319
      setVariableNameInjector( vin );
320
    }
321
322
    return this.variableNameInjector;
323
  }
324
325
  private VariableNameInjector createVariableNameInjector() {
326
    final FileEditorTab tab = getActiveFileEditor();
327
    final DefinitionPane pane = getDefinitionPane();
328
329
    return new VariableNameInjector( tab, pane );
330
  }
331
332
  /**
333
   * Called whenever the preview pane becomes out of sync with the file editor
334
   * tab. This can be called when the text changes, the caret paragraph changes,
335
   * or the file tab changes.
336
   *
337
   * @param tab The file editor tab that has been changed in some fashion.
338
   */
339
  private void refreshSelectedTab( final FileEditorTab tab ) {
340
    if( tab == null ) {
341
      return;
342
    }
343
344
    getPreviewPane().setPath( tab.getPath() );
345
346
    // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
347
    final Position p = tab.getCaretOffset();
348
    getLineNumberText().setText(
349
        get( STATUS_BAR_LINE,
350
             p.getMajor() + 1,
351
             p.getMinor() + 1,
352
             tab.getCaretPosition() + 1
353
        )
354
    );
355
356
    Processor<String> processor = getProcessors().get( tab );
357
358
    if( processor == null ) {
359
      processor = createProcessor( tab );
360
      getProcessors().put( tab, processor );
361
    }
362
363
    try {
364
      processor.processChain( tab.getEditorText() );
365
    } catch( final Exception ex ) {
366
      error( ex );
367
    }
368
  }
369
370
  private void refreshActiveTab() {
371
    refreshSelectedTab( getActiveFileEditor() );
372
  }
373
374
  /**
375
   * Used to find text in the active file editor window.
376
   */
377
  private void find() {
378
    final TextField input = getFindTextField();
379
    getStatusBar().setGraphic( input );
380
    input.requestFocus();
381
  }
382
383
  public void findNext() {
384
    getActiveFileEditor().searchNext( getFindTextField().getText() );
385
  }
386
387
  /**
388
   * Returns the variable map of interpolated definitions.
389
   *
390
   * @return A map to help dereference variables.
391
   */
392
  private Map<String, String> getResolvedMap() {
393
    return mResolvedMap;
394
  }
395
396
  private void interpolateResolvedMap() {
397
    final Map<String, String> treeMap = getDefinitionPane().toMap();
398
    final Map<String, String> map = new HashMap<>( treeMap );
399
    MapInterpolator.interpolate( map );
400
401
    getResolvedMap().clear();
402
    getResolvedMap().putAll( map );
403
  }
404
405
  /**
406
   * Called when a definition source is opened.
407
   *
408
   * @param path Path to the definition source that was opened.
409
   */
410
  private void openDefinitions( final Path path ) {
411
    try {
412
      final DefinitionSource ds = createDefinitionSource( path );
413
      setDefinitionSource( ds );
414
      storeDefinitionSourceFilename( path );
415
416
      final DefinitionPane pane = getDefinitionPane();
417
      pane.update( ds );
418
      pane.addTreeChangeHandler( mHandler );
87
import static javafx.scene.input.KeyCode.ENTER;
88
import static javafx.scene.input.KeyCode.TAB;
89
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
90
91
/**
92
 * Main window containing a tab pane in the center for file editors.
93
 *
94
 * @author Karl Tauber and White Magic Software, Ltd.
95
 */
96
public class MainWindow implements Observer {
97
98
  private final Options mOptions = Services.load( Options.class );
99
  private final Snitch mSnitch = Services.load( Snitch.class );
100
  private final Settings mSettings = Services.load( Settings.class );
101
  private final Notifier mNotifier = Services.load( Notifier.class );
102
103
  private final Scene mScene;
104
  private final StatusBar mStatusBar;
105
  private final Text mLineNumberText;
106
  private final TextField mFindTextField;
107
108
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
109
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
110
  private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane();
111
  private FileEditorTabPane fileEditorPane;
112
113
  /**
114
   * Prevents re-instantiation of processing classes.
115
   */
116
  private final Map<FileEditorTab, Processor<String>> mProcessors =
117
      new HashMap<>();
118
119
  private final Map<String, String> mResolvedMap =
120
      new HashMap<>( DEFAULT_MAP_SIZE );
121
122
  /**
123
   * Listens on the definition pane for double-click events.
124
   */
125
  private VariableNameInjector variableNameInjector;
126
127
  /**
128
   * Called when the definition data is changed.
129
   */
130
  final EventHandler<TreeItem.TreeModificationEvent<Event>> mTreeHandler =
131
      event -> {
132
        exportDefinitions( getDefinitionPath() );
133
        interpolateResolvedMap();
134
        refreshActiveTab();
135
      };
136
137
  final EventHandler<? super KeyEvent> mDefinitionKeyHandler =
138
      event -> {
139
        if( event.getCode() == ENTER ) {
140
          getVariableNameInjector().injectSelectedItem();
141
        }
142
      };
143
144
  final EventHandler<? super KeyEvent> mEditorKeyHandler =
145
      (EventHandler<KeyEvent>) event -> {
146
        if( event.getCode() == TAB ) {
147
          getDefinitionPane().requestFocus();
148
          event.consume();
149
        }
150
      };
151
152
  public MainWindow() {
153
    mStatusBar = createStatusBar();
154
    mLineNumberText = createLineNumberText();
155
    mFindTextField = createFindTextField();
156
    mScene = createScene();
157
158
    initLayout();
159
    initFindInput();
160
    initSnitch();
161
    initDefinitionListener();
162
    initTabAddedListener();
163
    initTabChangedListener();
164
    initPreferences();
165
  }
166
167
  /**
168
   * Watch for changes to external files. In particular, this awaits
169
   * modifications to any XSL files associated with XML files being edited. When
170
   * an XSL file is modified (external to the application), the snitch's ears
171
   * perk up and the file is reloaded. This keeps the XSL transformation up to
172
   * date with what's on the file system.
173
   */
174
  private void initSnitch() {
175
    getSnitch().addObserver( this );
176
  }
177
178
  /**
179
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
180
   * presses.
181
   */
182
  private void initFindInput() {
183
    final TextField input = getFindTextField();
184
185
    input.setOnKeyPressed( ( KeyEvent event ) -> {
186
      switch( event.getCode() ) {
187
        case F3:
188
        case ENTER:
189
          findNext();
190
          break;
191
        case F:
192
          if( !event.isControlDown() ) {
193
            break;
194
          }
195
        case ESCAPE:
196
          getStatusBar().setGraphic( null );
197
          getActiveFileEditor().getEditorPane().requestFocus();
198
          break;
199
      }
200
    } );
201
202
    // Remove when the input field loses focus.
203
    input.focusedProperty().addListener(
204
        (
205
            final ObservableValue<? extends Boolean> focused,
206
            final Boolean oFocus,
207
            final Boolean nFocus ) -> {
208
          if( !nFocus ) {
209
            getStatusBar().setGraphic( null );
210
          }
211
        }
212
    );
213
  }
214
215
  /**
216
   * Listen for {@link FileEditorTabPane} to receive open definition file event.
217
   */
218
  private void initDefinitionListener() {
219
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
220
        ( final ObservableValue<? extends Path> file,
221
          final Path oldPath, final Path newPath ) -> {
222
          // Indirectly refresh the resolved map.
223
          resetProcessors();
224
225
          openDefinitions( newPath );
226
227
          // Will create new processors and therefore a new resolved map.
228
          refreshActiveTab();
229
        }
230
    );
231
  }
232
233
  /**
234
   * When tabs are added, hook the various change listeners onto the new tab so
235
   * that the preview pane refreshes as necessary.
236
   */
237
  private void initTabAddedListener() {
238
    final FileEditorTabPane editorPane = getFileEditorPane();
239
240
    // Make sure the text processor kicks off when new files are opened.
241
    final ObservableList<Tab> tabs = editorPane.getTabs();
242
243
    // Update the preview pane on tab changes.
244
    tabs.addListener(
245
        ( final Change<? extends Tab> change ) -> {
246
          while( change.next() ) {
247
            if( change.wasAdded() ) {
248
              // Multiple tabs can be added simultaneously.
249
              for( final Tab newTab : change.getAddedSubList() ) {
250
                final FileEditorTab tab = (FileEditorTab) newTab;
251
252
                initTextChangeListener( tab );
253
                initCaretParagraphListener( tab );
254
                initKeyboardEventListeners( tab );
255
//              initSyntaxListener( tab );
256
              }
257
            }
258
          }
259
        }
260
    );
261
  }
262
263
  /**
264
   * Reloads the preferences from the previous session.
265
   */
266
  private void initPreferences() {
267
    restoreDefinitionPane();
268
    getFileEditorPane().restorePreferences();
269
  }
270
271
  /**
272
   * Listen for new tab selection events.
273
   */
274
  private void initTabChangedListener() {
275
    final FileEditorTabPane editorPane = getFileEditorPane();
276
277
    // Update the preview pane changing tabs.
278
    editorPane.addTabSelectionListener(
279
        ( ObservableValue<? extends Tab> tabPane,
280
          final Tab oldTab, final Tab newTab ) -> {
281
          updateVariableNameInjector();
282
283
          // If there was no old tab, then this is a first time load, which
284
          // can be ignored.
285
          if( oldTab != null ) {
286
            if( newTab == null ) {
287
              closeRemainingTab();
288
            }
289
            else {
290
              // Update the preview with the edited text.
291
              refreshSelectedTab( (FileEditorTab) newTab );
292
            }
293
          }
294
        }
295
    );
296
  }
297
298
  /**
299
   * Ensure that the keyboard events are received when a new tab is added
300
   * to the user interface.
301
   *
302
   * @param tab The tab that can trigger keyboard events, such as control+space.
303
   */
304
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
305
    final VariableNameInjector vin = getVariableNameInjector();
306
    vin.initKeyboardEventListeners( tab );
307
308
    tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler );
309
  }
310
311
  private void initTextChangeListener( final FileEditorTab tab ) {
312
    tab.addTextChangeListener(
313
        ( ObservableValue<? extends String> editor,
314
          final String oldValue, final String newValue ) ->
315
            refreshSelectedTab( tab )
316
    );
317
  }
318
319
  private void initCaretParagraphListener( final FileEditorTab tab ) {
320
    tab.addCaretParagraphListener(
321
        ( ObservableValue<? extends Integer> editor,
322
          final Integer oldValue, final Integer newValue ) ->
323
            refreshSelectedTab( tab )
324
    );
325
  }
326
327
  private void updateVariableNameInjector() {
328
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
329
  }
330
331
  private void setVariableNameInjector( final VariableNameInjector injector ) {
332
    this.variableNameInjector = injector;
333
  }
334
335
  private synchronized VariableNameInjector getVariableNameInjector() {
336
    if( this.variableNameInjector == null ) {
337
      final VariableNameInjector vin = createVariableNameInjector();
338
      setVariableNameInjector( vin );
339
    }
340
341
    return this.variableNameInjector;
342
  }
343
344
  private VariableNameInjector createVariableNameInjector() {
345
    final FileEditorTab tab = getActiveFileEditor();
346
    final DefinitionPane pane = getDefinitionPane();
347
348
    return new VariableNameInjector( tab, pane );
349
  }
350
351
  /**
352
   * Called whenever the preview pane becomes out of sync with the file editor
353
   * tab. This can be called when the text changes, the caret paragraph changes,
354
   * or the file tab changes.
355
   *
356
   * @param tab The file editor tab that has been changed in some fashion.
357
   */
358
  private void refreshSelectedTab( final FileEditorTab tab ) {
359
    if( tab == null ) {
360
      return;
361
    }
362
363
    getPreviewPane().setPath( tab.getPath() );
364
365
    // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
366
    final Position p = tab.getCaretOffset();
367
    getLineNumberText().setText(
368
        get( STATUS_BAR_LINE,
369
             p.getMajor() + 1,
370
             p.getMinor() + 1,
371
             tab.getCaretPosition() + 1
372
        )
373
    );
374
375
    Processor<String> processor = getProcessors().get( tab );
376
377
    if( processor == null ) {
378
      processor = createProcessor( tab );
379
      getProcessors().put( tab, processor );
380
    }
381
382
    try {
383
      processor.processChain( tab.getEditorText() );
384
    } catch( final Exception ex ) {
385
      error( ex );
386
    }
387
  }
388
389
  private void refreshActiveTab() {
390
    refreshSelectedTab( getActiveFileEditor() );
391
  }
392
393
  /**
394
   * Used to find text in the active file editor window.
395
   */
396
  private void find() {
397
    final TextField input = getFindTextField();
398
    getStatusBar().setGraphic( input );
399
    input.requestFocus();
400
  }
401
402
  public void findNext() {
403
    getActiveFileEditor().searchNext( getFindTextField().getText() );
404
  }
405
406
  /**
407
   * Returns the variable map of interpolated definitions.
408
   *
409
   * @return A map to help dereference variables.
410
   */
411
  private Map<String, String> getResolvedMap() {
412
    return mResolvedMap;
413
  }
414
415
  private void interpolateResolvedMap() {
416
    final Map<String, String> treeMap = getDefinitionPane().toMap();
417
    final Map<String, String> map = new HashMap<>( treeMap );
418
    MapInterpolator.interpolate( map );
419
420
    getResolvedMap().clear();
421
    getResolvedMap().putAll( map );
422
  }
423
424
  /**
425
   * Called when a definition source is opened.
426
   *
427
   * @param path Path to the definition source that was opened.
428
   */
429
  private void openDefinitions( final Path path ) {
430
    try {
431
      final DefinitionSource ds = createDefinitionSource( path );
432
      setDefinitionSource( ds );
433
      storeDefinitionSourceFilename( path );
434
435
      final DefinitionPane pane = getDefinitionPane();
436
      pane.update( ds );
437
      pane.addTreeChangeHandler( mTreeHandler );
438
      pane.addKeyEventHandler( mDefinitionKeyHandler );
419439
420440
      interpolateResolvedMap();
M src/main/java/com/scrivenvar/decorators/YamlVariableDecorator.java
2828
package com.scrivenvar.decorators;
2929
30
import java.util.regex.Pattern;
31
3230
/**
3331
 * Brackets variable names with dollar symbols.
3432
 *
3533
 * @author White Magic Software, Ltd.
3634
 */
3735
public class YamlVariableDecorator implements VariableDecorator {
38
39
  /**
40
   * Matches variables delimited by dollar symbols. The outer group is necessary
41
   * for substring replacement of delimited references.
42
   */
43
  public final static String REGEX = "(\\$(.*?)\\$)";
44
45
  /**
46
   * Compiled regular expression for matching delimited references.
47
   */
48
  public final static Pattern REGEX_PATTERN = Pattern.compile( REGEX );
4936
5037
  /**
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
3838
import javafx.util.StringConverter;
3939
40
import java.util.LinkedList;
41
import java.util.List;
42
import java.util.Map;
43
44
import static com.scrivenvar.Messages.get;
45
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
46
47
/**
48
 * Provides the user interface that holdsa {@link TreeView}, which
49
 * allows users to interact with key/value pairs loaded from the
50
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
51
 *
52
 * @author White Magic Software, Ltd.
53
 */
54
public final class DefinitionPane extends AbstractPane {
55
56
  /**
57
   * Trimmed off the end of a word to match a variable name.
58
   */
59
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
60
61
  /**
62
   * Contains a view of the definitions.
63
   */
64
  private final TreeView<String> mTreeView = new TreeView<>();
65
66
  /**
67
   * Constructs a definition pane with a given tree view root.
68
   */
69
  public DefinitionPane() {
70
    final var treeView = getTreeView();
71
    treeView.setEditable( true );
72
    treeView.setCellFactory( cell -> createTreeCell() );
73
    treeView.setContextMenu( createContextMenu() );
74
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
75
    treeView.setShowRoot( false );
76
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
77
  }
78
79
  /**
80
   * Changes the root of the {@link TreeView} to the root of the
81
   * {@link TreeView} from the {@link DefinitionSource}.
82
   *
83
   * @param definitionSource Container for the hierarchy of key/value pairs
84
   *                         to replace the existing hierarchy.
85
   */
86
  public void update( final DefinitionSource definitionSource ) {
87
    assert definitionSource != null;
88
89
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
90
    final TreeItem<String> root = treeAdapter.adapt(
91
        get( "Pane.definition.node.root.title" )
92
    );
93
94
    getTreeView().setRoot( root );
95
  }
96
97
  public Map<String, String> toMap() {
98
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
99
  }
100
101
  /**
102
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
103
   * is modified. The modifications include: item value changes, item additions,
104
   * and item removals.
105
   * <p>
106
   * Safe to call multiple times; if a handler is already registered, the
107
   * old handler is used.
108
   * </p>
109
   *
110
   * @param handler The handler to call whenever any {@link TreeItem} changes.
111
   */
112
  public void addTreeChangeHandler(
113
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
114
    final TreeItem<String> root = getTreeView().getRoot();
115
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
116
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
117
  }
118
119
  /**
120
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
121
   * well-formed for export. A tree is considered well-formed if the following
122
   * conditions are met:
123
   *
124
   * <ul>
125
   *   <li>The root node contains at least one child node having a leaf.</li>
126
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
127
   * </ul>
128
   *
129
   * @return {@code null} if the document is well-formed, otherwise the
130
   * problematic child {@link TreeItem}.
131
   */
132
  public TreeItem<String> isTreeWellFormed() {
133
    final var root = getTreeView().getRoot();
134
135
    for( final var child : root.getChildren() ) {
136
      final var problemChild = isWellFormed( child );
137
138
      if( child.isLeaf() || problemChild != null ) {
139
        return problemChild;
140
      }
141
    }
142
143
    return null;
144
  }
145
146
  /**
147
   * Determines whether the document is well-formed by ensuring that
148
   * child branches do not contain multiple leaves.
149
   *
150
   * @param item The sub-tree to check for well-formedness.
151
   * @return {@code null} when the tree is well-formed, otherwise the
152
   * problematic {@link TreeItem}.
153
   */
154
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
155
    int childLeafs = 0;
156
    int childBranches = 0;
157
158
    for( final TreeItem<String> child : item.getChildren() ) {
159
      if( child.isLeaf() ) {
160
        childLeafs++;
161
      }
162
      else {
163
        childBranches++;
164
      }
165
166
      final var problemChild = isWellFormed( child );
167
168
      if( problemChild != null ) {
169
        return problemChild;
170
      }
171
    }
172
173
    return ((childBranches > 0 && childLeafs == 0) ||
174
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
175
  }
176
177
  /**
178
   * Returns the leaf that matches the given value. If the value is terminally
179
   * punctuated, the punctuation is removed if no match was found.
180
   *
181
   * @param value    The value to find, never null.
182
   * @param findMode Defines how to match words.
183
   * @return The leaf that contains the given value, or null if neither the
184
   * original value nor the terminally-trimmed value was found.
185
   */
186
  public VariableTreeItem<String> findLeaf(
187
      final String value, final FindMode findMode ) {
188
    final VariableTreeItem<String> root = getTreeRoot();
189
    final VariableTreeItem<String> leaf = root.findLeaf( value, findMode );
190
191
    return leaf == null
192
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
193
        : leaf;
194
  }
195
196
  /**
197
   * Removes punctuation from the end of a string.
198
   *
199
   * @param s The string to trim, never null.
200
   * @return The string trimmed of all terminal characters from the end
201
   */
202
  private String rtrimTerminalPunctuation( final String s ) {
203
    assert s != null;
204
    int index = s.length() - 1;
205
206
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
207
      index--;
208
    }
209
210
    return s.substring( 0, index );
211
  }
212
213
  /**
214
   * Expands the node to the root, recursively.
215
   *
216
   * @param <T>  The type of tree item to expand (usually String).
217
   * @param node The node to expand.
218
   */
219
  public <T> void expand( final TreeItem<T> node ) {
220
    if( node != null ) {
221
      expand( node.getParent() );
222
223
      if( !node.isLeaf() ) {
224
        node.setExpanded( true );
225
      }
226
    }
227
  }
228
229
  public void select( final TreeItem<String> item ) {
230
    getSelectionModel().clearSelection();
231
    getSelectionModel().select( getTreeView().getRow( item ) );
232
  }
233
234
  /**
235
   * Collapses the tree, recursively.
236
   */
237
  public void collapse() {
238
    collapse( getTreeRoot().getChildren() );
239
  }
240
241
  /**
242
   * Collapses the tree, recursively.
243
   *
244
   * @param <T>   The type of tree item to expand (usually String).
245
   * @param nodes The nodes to collapse.
246
   */
247
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
248
    for( final TreeItem<T> node : nodes ) {
249
      node.setExpanded( false );
250
      collapse( node.getChildren() );
251
    }
252
  }
253
254
  /**
255
   * @return {@code true} when the user is editing a {@link TreeItem}.
256
   */
257
  private boolean isEditingTreeItem() {
258
    return getTreeView().editingItemProperty().getValue() != null;
259
  }
260
261
  /**
262
   * Changes to edit mode for the selected item.
263
   */
264
  private void editSelectedItem() {
265
    getTreeView().edit( getSelectedItem() );
266
  }
267
268
  /**
269
   * Removes all selected items from the {@link TreeView}.
270
   */
271
  private void deleteSelectedItems() {
272
    for( final TreeItem<String> item : getSelectedItems() ) {
273
      final TreeItem<String> parent = item.getParent();
274
275
      if( parent != null ) {
276
        parent.getChildren().remove( item );
277
      }
278
    }
279
  }
280
281
  /**
282
   * Deletes the selected item.
283
   */
284
  private void deleteSelectedItem() {
285
    final TreeItem<String> c = getSelectedItem();
286
    getSiblings( c ).remove( c );
287
  }
288
289
  /**
290
   * Adds a new item under the selected item (or root if nothing is selected).
291
   * There are a few conditions to consider: when adding to the root,
292
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
293
   * root must contain two items: a key and a value.
294
   */
295
  private void addItem() {
296
    final TreeItem<String> value = createTreeItem();
297
    getSelectedItem().getChildren().add( value );
298
    expand( value );
299
    select( value );
300
  }
301
302
  private ContextMenu createContextMenu() {
303
    final ContextMenu menu = new ContextMenu();
304
    final ObservableList<MenuItem> items = menu.getItems();
305
306
    addMenuItem( items, "Definition.menu.create" )
307
        .setOnAction( e -> addItem() );
308
309
    addMenuItem( items, "Definition.menu.rename" )
310
        .setOnAction( e -> editSelectedItem() );
311
312
    addMenuItem( items, "Definition.menu.remove" )
313
        .setOnAction( e -> deleteSelectedItem() );
314
315
    return menu;
316
  }
317
318
  /**
319
   * Executes hot-keys for edits to the definition tree.
320
   *
321
   * @param event Contains the key code of the key that was pressed.
322
   */
323
  private void keyEventFilter( final KeyEvent event ) {
324
    if( !isEditingTreeItem() ) {
325
      switch( event.getCode() ) {
326
        case ENTER:
327
          expand( getSelectedItem() );
328
          event.consume();
329
330
          break;
331
332
        case DELETE:
333
          deleteSelectedItems();
334
          break;
335
336
        case INSERT:
337
          addItem();
338
          break;
339
340
        case R:
341
          if( event.isControlDown() ) {
342
            editSelectedItem();
343
          }
344
345
          break;
346
      }
347
    }
348
  }
349
350
  /**
351
   * Adds a menu item to a list of menu items.
352
   *
353
   * @param items    The list of menu items to append to.
354
   * @param labelKey The resource bundle key name for the menu item's label.
355
   * @return The menu item added to the list of menu items.
356
   */
357
  private MenuItem addMenuItem(
358
      final List<MenuItem> items, final String labelKey ) {
359
    final MenuItem menuItem = createMenuItem( labelKey );
360
    items.add( menuItem );
361
    return menuItem;
362
  }
363
364
  private MenuItem createMenuItem( final String labelKey ) {
365
    return new MenuItem( get( labelKey ) );
366
  }
367
368
  private VariableTreeItem<String> createTreeItem() {
369
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
370
  }
371
372
  private TreeCell<String> createTreeCell() {
373
    return new TextFieldTreeCell<>(
374
        createStringConverter() ) {
375
      @Override
376
      public void commitEdit( final String newValue ) {
377
        super.commitEdit( newValue );
378
        requestFocus();
379
        select( getTreeItem() );
380
      }
381
    };
382
  }
383
384
  private StringConverter<String> createStringConverter() {
385
    return new StringConverter<>() {
386
      @Override
387
      public String toString( final String object ) {
388
        return object == null ? "" : object;
389
      }
390
391
      @Override
392
      public String fromString( final String string ) {
393
        return string == null ? "" : string;
394
      }
395
    };
396
  }
397
398
  /**
399
   * Returns the tree view that contains the definition hierarchy.
400
   *
401
   * @return A non-null instance.
402
   */
403
  public TreeView<String> getTreeView() {
404
    return mTreeView;
405
  }
406
407
  /**
408
   * Returns the root node to the tree view.
409
   *
410
   * @return getTreeView()
411
   */
412
  public Node getNode() {
413
    return getTreeView();
414
  }
415
416
  /**
417
   * Returns the root of the tree.
418
   *
419
   * @return The first node added to the definition tree.
420
   */
421
  private VariableTreeItem<String> getTreeRoot() {
422
    final TreeItem<String> root = getTreeView().getRoot();
423
424
    return root instanceof VariableTreeItem ?
425
        (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" );
426
  }
427
428
  private ObservableList<TreeItem<String>> getSiblings(
429
      final TreeItem<String> item ) {
430
    final TreeItem<String> root = getTreeView().getRoot();
431
    final TreeItem<String> parent =
432
        (item == null || item == root) ? root : item.getParent();
433
434
    return parent.getChildren();
435
  }
436
437
  private MultipleSelectionModel<TreeItem<String>> getSelectionModel() {
438
    return getTreeView().getSelectionModel();
439
  }
440
441
  /**
442
   * Returns a copy of all the selected items.
443
   *
444
   * @return A list, possibly empty, containing all selected items in the
445
   * {@link TreeView}.
446
   */
447
  private List<TreeItem<String>> getSelectedItems() {
448
    return new LinkedList<>( getSelectionModel().getSelectedItems() );
449
  }
450
451
  private TreeItem<String> getSelectedItem() {
452
    final TreeItem<String> item = getSelectionModel().getSelectedItem();
453
    return item == null ? getTreeView().getRoot() : item;
40
import java.util.*;
41
42
import static com.scrivenvar.Messages.get;
43
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
44
45
/**
46
 * Provides the user interface that holdsa {@link TreeView}, which
47
 * allows users to interact with key/value pairs loaded from the
48
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
49
 *
50
 * @author White Magic Software, Ltd.
51
 */
52
public final class DefinitionPane extends AbstractPane {
53
54
  /**
55
   * Trimmed off the end of a word to match a variable name.
56
   */
57
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
58
59
  /**
60
   * Contains a view of the definitions.
61
   */
62
  private final TreeView<String> mTreeView = new TreeView<>();
63
64
  /**
65
   * Handlers for key press events.
66
   */
67
  private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers
68
      = new HashSet<>();
69
70
  /**
71
   * Constructs a definition pane with a given tree view root.
72
   */
73
  public DefinitionPane() {
74
    final var treeView = getTreeView();
75
    treeView.setEditable( true );
76
    treeView.setCellFactory( cell -> createTreeCell() );
77
    treeView.setContextMenu( createContextMenu() );
78
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
79
    treeView.setShowRoot( false );
80
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
81
  }
82
83
  /**
84
   * Changes the root of the {@link TreeView} to the root of the
85
   * {@link TreeView} from the {@link DefinitionSource}.
86
   *
87
   * @param definitionSource Container for the hierarchy of key/value pairs
88
   *                         to replace the existing hierarchy.
89
   */
90
  public void update( final DefinitionSource definitionSource ) {
91
    assert definitionSource != null;
92
93
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
94
    final TreeItem<String> root = treeAdapter.adapt(
95
        get( "Pane.definition.node.root.title" )
96
    );
97
98
    getTreeView().setRoot( root );
99
  }
100
101
  public Map<String, String> toMap() {
102
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
103
  }
104
105
  /**
106
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
107
   * is modified. The modifications include: item value changes, item additions,
108
   * and item removals.
109
   * <p>
110
   * Safe to call multiple times; if a handler is already registered, the
111
   * old handler is used.
112
   * </p>
113
   *
114
   * @param handler The handler to call whenever any {@link TreeItem} changes.
115
   */
116
  public void addTreeChangeHandler(
117
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
118
    final TreeItem<String> root = getTreeView().getRoot();
119
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
120
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
121
  }
122
123
  public void addKeyEventHandler(
124
      final EventHandler<? super KeyEvent> handler ) {
125
    getKeyEventHandlers().add( handler );
126
  }
127
128
  /**
129
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
130
   * well-formed for export. A tree is considered well-formed if the following
131
   * conditions are met:
132
   *
133
   * <ul>
134
   *   <li>The root node contains at least one child node having a leaf.</li>
135
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
136
   * </ul>
137
   *
138
   * @return {@code null} if the document is well-formed, otherwise the
139
   * problematic child {@link TreeItem}.
140
   */
141
  public TreeItem<String> isTreeWellFormed() {
142
    final var root = getTreeView().getRoot();
143
144
    for( final var child : root.getChildren() ) {
145
      final var problemChild = isWellFormed( child );
146
147
      if( child.isLeaf() || problemChild != null ) {
148
        return problemChild;
149
      }
150
    }
151
152
    return null;
153
  }
154
155
  /**
156
   * Determines whether the document is well-formed by ensuring that
157
   * child branches do not contain multiple leaves.
158
   *
159
   * @param item The sub-tree to check for well-formedness.
160
   * @return {@code null} when the tree is well-formed, otherwise the
161
   * problematic {@link TreeItem}.
162
   */
163
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
164
    int childLeafs = 0;
165
    int childBranches = 0;
166
167
    for( final TreeItem<String> child : item.getChildren() ) {
168
      if( child.isLeaf() ) {
169
        childLeafs++;
170
      }
171
      else {
172
        childBranches++;
173
      }
174
175
      final var problemChild = isWellFormed( child );
176
177
      if( problemChild != null ) {
178
        return problemChild;
179
      }
180
    }
181
182
    return ((childBranches > 0 && childLeafs == 0) ||
183
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
184
  }
185
186
  /**
187
   * Returns the leaf that matches the given value. If the value is terminally
188
   * punctuated, the punctuation is removed if no match was found.
189
   *
190
   * @param value    The value to find, never null.
191
   * @param findMode Defines how to match words.
192
   * @return The leaf that contains the given value, or null if neither the
193
   * original value nor the terminally-trimmed value was found.
194
   */
195
  public VariableTreeItem<String> findLeaf(
196
      final String value, final FindMode findMode ) {
197
    final VariableTreeItem<String> root = getTreeRoot();
198
    final VariableTreeItem<String> leaf = root.findLeaf( value, findMode );
199
200
    return leaf == null
201
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
202
        : leaf;
203
  }
204
205
  /**
206
   * Removes punctuation from the end of a string.
207
   *
208
   * @param s The string to trim, never null.
209
   * @return The string trimmed of all terminal characters from the end
210
   */
211
  private String rtrimTerminalPunctuation( final String s ) {
212
    assert s != null;
213
    int index = s.length() - 1;
214
215
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
216
      index--;
217
    }
218
219
    return s.substring( 0, index );
220
  }
221
222
  /**
223
   * Expands the node to the root, recursively.
224
   *
225
   * @param <T>  The type of tree item to expand (usually String).
226
   * @param node The node to expand.
227
   */
228
  public <T> void expand( final TreeItem<T> node ) {
229
    if( node != null ) {
230
      expand( node.getParent() );
231
232
      if( !node.isLeaf() ) {
233
        node.setExpanded( true );
234
      }
235
    }
236
  }
237
238
  public void select( final TreeItem<String> item ) {
239
    getSelectionModel().clearSelection();
240
    getSelectionModel().select( getTreeView().getRow( item ) );
241
  }
242
243
  /**
244
   * Collapses the tree, recursively.
245
   */
246
  public void collapse() {
247
    collapse( getTreeRoot().getChildren() );
248
  }
249
250
  /**
251
   * Collapses the tree, recursively.
252
   *
253
   * @param <T>   The type of tree item to expand (usually String).
254
   * @param nodes The nodes to collapse.
255
   */
256
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
257
    for( final TreeItem<T> node : nodes ) {
258
      node.setExpanded( false );
259
      collapse( node.getChildren() );
260
    }
261
  }
262
263
  /**
264
   * @return {@code true} when the user is editing a {@link TreeItem}.
265
   */
266
  private boolean isEditingTreeItem() {
267
    return getTreeView().editingItemProperty().getValue() != null;
268
  }
269
270
  /**
271
   * Changes to edit mode for the selected item.
272
   */
273
  private void editSelectedItem() {
274
    getTreeView().edit( getSelectedItem() );
275
  }
276
277
  /**
278
   * Removes all selected items from the {@link TreeView}.
279
   */
280
  private void deleteSelectedItems() {
281
    for( final TreeItem<String> item : getSelectedItems() ) {
282
      final TreeItem<String> parent = item.getParent();
283
284
      if( parent != null ) {
285
        parent.getChildren().remove( item );
286
      }
287
    }
288
  }
289
290
  /**
291
   * Deletes the selected item.
292
   */
293
  private void deleteSelectedItem() {
294
    final TreeItem<String> c = getSelectedItem();
295
    getSiblings( c ).remove( c );
296
  }
297
298
  /**
299
   * Adds a new item under the selected item (or root if nothing is selected).
300
   * There are a few conditions to consider: when adding to the root,
301
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
302
   * root must contain two items: a key and a value.
303
   */
304
  private void addItem() {
305
    final TreeItem<String> value = createTreeItem();
306
    getSelectedItem().getChildren().add( value );
307
    expand( value );
308
    select( value );
309
  }
310
311
  private ContextMenu createContextMenu() {
312
    final ContextMenu menu = new ContextMenu();
313
    final ObservableList<MenuItem> items = menu.getItems();
314
315
    addMenuItem( items, "Definition.menu.create" )
316
        .setOnAction( e -> addItem() );
317
318
    addMenuItem( items, "Definition.menu.rename" )
319
        .setOnAction( e -> editSelectedItem() );
320
321
    addMenuItem( items, "Definition.menu.remove" )
322
        .setOnAction( e -> deleteSelectedItem() );
323
324
    return menu;
325
  }
326
327
  /**
328
   * Executes hot-keys for edits to the definition tree.
329
   *
330
   * @param event Contains the key code of the key that was pressed.
331
   */
332
  private void keyEventFilter( final KeyEvent event ) {
333
    if( !isEditingTreeItem() ) {
334
      switch( event.getCode() ) {
335
        case ENTER:
336
          expand( getSelectedItem() );
337
          event.consume();
338
          break;
339
340
        case DELETE:
341
          deleteSelectedItems();
342
          break;
343
344
        case INSERT:
345
          addItem();
346
          break;
347
348
        case R:
349
          if( event.isControlDown() ) {
350
            editSelectedItem();
351
          }
352
353
          break;
354
      }
355
356
      for( final var handler : getKeyEventHandlers() ) {
357
        handler.handle( event );
358
      }
359
    }
360
  }
361
362
  /**
363
   * Adds a menu item to a list of menu items.
364
   *
365
   * @param items    The list of menu items to append to.
366
   * @param labelKey The resource bundle key name for the menu item's label.
367
   * @return The menu item added to the list of menu items.
368
   */
369
  private MenuItem addMenuItem(
370
      final List<MenuItem> items, final String labelKey ) {
371
    final MenuItem menuItem = createMenuItem( labelKey );
372
    items.add( menuItem );
373
    return menuItem;
374
  }
375
376
  private MenuItem createMenuItem( final String labelKey ) {
377
    return new MenuItem( get( labelKey ) );
378
  }
379
380
  private VariableTreeItem<String> createTreeItem() {
381
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
382
  }
383
384
  private TreeCell<String> createTreeCell() {
385
    return new TextFieldTreeCell<>(
386
        createStringConverter() ) {
387
      @Override
388
      public void commitEdit( final String newValue ) {
389
        super.commitEdit( newValue );
390
        select( getTreeItem() );
391
        requestFocus();
392
      }
393
    };
394
  }
395
396
  @Override
397
  public void requestFocus() {
398
    super.requestFocus();
399
    getTreeView().requestFocus();
400
  }
401
402
  private StringConverter<String> createStringConverter() {
403
    return new StringConverter<>() {
404
      @Override
405
      public String toString( final String object ) {
406
        return object == null ? "" : object;
407
      }
408
409
      @Override
410
      public String fromString( final String string ) {
411
        return string == null ? "" : string;
412
      }
413
    };
414
  }
415
416
  /**
417
   * Returns the tree view that contains the definition hierarchy.
418
   *
419
   * @return A non-null instance.
420
   */
421
  public TreeView<String> getTreeView() {
422
    return mTreeView;
423
  }
424
425
  /**
426
   * Returns the root node to the tree view.
427
   *
428
   * @return getTreeView()
429
   */
430
  public Node getNode() {
431
    return getTreeView();
432
  }
433
434
  /**
435
   * Returns the root of the tree.
436
   *
437
   * @return The first node added to the definition tree.
438
   */
439
  private VariableTreeItem<String> getTreeRoot() {
440
    final TreeItem<String> root = getTreeView().getRoot();
441
442
    return root instanceof VariableTreeItem ?
443
        (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" );
444
  }
445
446
  private ObservableList<TreeItem<String>> getSiblings(
447
      final TreeItem<String> item ) {
448
    final TreeItem<String> root = getTreeView().getRoot();
449
    final TreeItem<String> parent =
450
        (item == null || item == root) ? root : item.getParent();
451
452
    return parent.getChildren();
453
  }
454
455
  private MultipleSelectionModel<TreeItem<String>> getSelectionModel() {
456
    return getTreeView().getSelectionModel();
457
  }
458
459
  /**
460
   * Returns a copy of all the selected items.
461
   *
462
   * @return A list, possibly empty, containing all selected items in the
463
   * {@link TreeView}.
464
   */
465
  private List<TreeItem<String>> getSelectedItems() {
466
    return new LinkedList<>( getSelectionModel().getSelectedItems() );
467
  }
468
469
  public TreeItem<String> getSelectedItem() {
470
    final TreeItem<String> item = getSelectionModel().getSelectedItem();
471
    return item == null ? getTreeView().getRoot() : item;
472
  }
473
474
  private Set<EventHandler<? super KeyEvent>> getKeyEventHandlers() {
475
    return mKeyEventHandlers;
454476
  }
455477
}
M src/main/java/com/scrivenvar/definition/FindMode.java
3333
 */
3434
public enum FindMode {
35
  EXACT,
3536
  CONTAINS,
3637
  STARTS_WITH,
M src/main/java/com/scrivenvar/definition/MapInterpolator.java
2828
package com.scrivenvar.definition;
2929
30
import com.scrivenvar.decorators.YamlVariableDecorator;
31
3230
import java.util.Map;
3331
import java.util.regex.Matcher;
34
35
import static com.scrivenvar.decorators.YamlVariableDecorator.REGEX_PATTERN;
32
import java.util.regex.Pattern;
3633
3734
/**
3835
 * Responsible for performing string interpolation on key/value pairs stored
3936
 * in a map. The values in the map can use a delimited syntax to refer to
4037
 * keys in the map.
4138
 *
4239
 * @author White Magic Software, Ltd.
4340
 */
4441
public class MapInterpolator {
42
43
  /**
44
   * Matches variables delimited by dollar symbols.
45
   */
46
  private final static String REGEX = "(\\$.*?\\$)";
47
48
  /**
49
   * Compiled regular expression for matching delimited references.
50
   */
51
  private final static Pattern REGEX_PATTERN = Pattern.compile( REGEX );
52
4553
  private final static int GROUP_DELIMITED = 1;
4654
...
5462
   * Performs string interpolation on the values in the given map. This will
5563
   * change any value in the map that contains a variable that matches
56
   * {@link YamlVariableDecorator#REGEX_PATTERN}.
64
   * {@link #REGEX_PATTERN}.
5765
   *
5866
   * @param map Contains values that represent references to keys.
...
7785
    while( matcher.find() ) {
7886
      final String keyName = matcher.group( GROUP_DELIMITED );
79
8087
      final String keyValue = resolve(
8188
          map, map.getOrDefault( keyName, keyName )
M src/main/java/com/scrivenvar/definition/TreeItemAdapter.java
6363
public class TreeItemAdapter {
6464
  /**
65
   * Separates YAML variable nodes (e.g., the dots in {@code $root.node.var$]).
65
   * Separates YAML variable nodes (e.g., the dots in {@code $root.node.var$}).
6666
   */
6767
  public static final String SEPARATOR = ".";
M src/main/java/com/scrivenvar/definition/VariableTreeItem.java
3333
import java.util.Stack;
3434
35
import static com.scrivenvar.definition.FindMode.CONTAINS;
36
import static com.scrivenvar.definition.FindMode.STARTS_WITH;
35
import static com.scrivenvar.definition.FindMode.*;
3736
import static java.text.Normalizer.Form.NFD;
3837
...
8786
      node = stack.pop();
8887
89
      if( findMode == CONTAINS && node.valueContains( text ) ) {
88
      if( findMode == EXACT && node.valueEquals( text ) ) {
89
        found = true;
90
      }
91
      else if( findMode == CONTAINS && node.valueContains( text ) ) {
9092
        found = true;
9193
      }
...
137139
  private boolean valueContains( final String s ) {
138140
    return isLeaf() && getDiacriticlessValue().contains( s );
141
  }
142
143
  /**
144
   * Returns true if this node is a leaf and its value equals the given text.
145
   *
146
   * @param s The text to compare against the node value.
147
   * @return true Node is a leaf and its value equals the given value.
148
   */
149
  private boolean valueEquals( final String s ) {
150
    return isLeaf() && getValue().equals( s );
139151
  }
140152
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
9090
9191
  /**
92
   * Inserts the variable
93
   */
94
  public void injectSelectedItem() {
95
    final TreeItem<String> item = getDefinitionPane().getSelectedItem();
96
97
    if( item.isLeaf() ) {
98
      // This avoids a direct typecast.
99
      final VariableTreeItem<String> leaf = getDefinitionPane().findLeaf(
100
          item.getValue(), FindMode.EXACT );
101
      final StyledTextArea<?, ?> editor = getEditor();
102
103
      editor.insertText( editor.getCaretPosition(), decorate( leaf ) );
104
    }
105
  }
106
107
  /**
92108
   * Traps Control+SPACE to auto-insert definition key names.
93109
   */
...
112128
113129
    if( leaf != null ) {
114
      replaceText(
115
          boundaries[ 0 ],
116
          boundaries[ 1 ],
117
          decorate( leaf.toPath() )
118
      );
119
130
      replaceText( boundaries[ 0 ], boundaries[ 1 ], decorate( leaf ) );
120131
      expand( leaf );
121132
    }
...
142153
143154
  /**
144
   * Injects a variable using the syntax specific to the type of document
155
   * Decorates a {@link TreeItem} using the syntax specific to the type of
156
   * document being edited.
157
   *
158
   * @param leaf The path to the leaf (the definition key) to be decorated.
159
   */
160
  private String decorate( final VariableTreeItem<String> leaf ) {
161
    return decorate( leaf.toPath() );
162
  }
163
164
  /**
165
   * Decorates a variable using the syntax specific to the type of document
145166
   * being edited.
146167
   *
147168
   * @param variable The variable to decorate in dot-notation without any
148
   *                 start or end tokens present.
169
   *                 start or end sigils present.
149170
   */
150171
  private String decorate( final String variable ) {
...
196217
    assert word != null;
197218
198
    VariableTreeItem<String> leaf;
219
    VariableTreeItem<String> leaf = findLeafExact( word );
199220
200
    leaf = findLeafStartsWith( word );
221
    leaf = leaf == null ? findLeafStartsWith( word ) : leaf;
201222
    leaf = leaf == null ? findLeafContains( word ) : leaf;
202223
    leaf = leaf == null ? findLeafLevenshtein( word ) : leaf;
203224
204225
    return leaf;
226
  }
227
228
  private VariableTreeItem<String> findLeafExact( final String text ) {
229
    return findLeaf( text, EXACT );
205230
  }
206231
A src/main/resources/com/scrivenvar/.gitignore
1
app.properties
12
M src/main/resources/com/scrivenvar/messages.properties
4242
Main.menu.insert.header_3=Header 3
4343
Main.menu.insert.header_3.prompt=header 3
44
Main.menu.insert.header_4=Header 4
45
Main.menu.insert.header_4.prompt=header 4
46
Main.menu.insert.header_5=Header 5
47
Main.menu.insert.header_5.prompt=header 5
48
Main.menu.insert.header_6=Header 6
49
Main.menu.insert.header_6.prompt=header 6
5044
Main.menu.insert.unordered_list=Unordered List
5145
Main.menu.insert.ordered_list=Ordered List