Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M CHANGES.md
11
# Change Log
22
3
## 0.7
4
5
- Load YAML variables from files
6
- Added cursor to the preview pane
7
- Reconfigured constants to use settings
8
- Organized MainWindow code by similar method calls
9
- Added single entry point for refreshing file editor tab
10
311
## 0.6
412
5
- Bug fixes synchronized scrolling
13
- Revised synchronized scrolling with preview panel
614
- Added universal character encoding detection
715
- Removed options panel
...
1422
- Added `Ctrl+Space` hot key for quick variable injection
1523
- Replaced commonmark-java with flexmark
16
- Added generic CARETPOSITION into document to scroll preview pane
24
- Insert `CARETPOSITION` into document for preview pane scroll position reference
1725
1826
## 0.4
...
3038
3139
## 0.2
40
3241
- RichTextFX (and dependencies) updated to version 0.6.10 (fixes bugs)
3342
- pegdown Markdown parser updated to version 1.6
3443
- Added five new pegdown 1.6 extension flags to Markdown Options tab
3544
- Minor improvements
3645
3746
## 0.1
3847
3948
- Initial release
40
M CREDITS.md
99
  * Vladimir Schneider: [flexmark](https://website.com)
1010
  * Jens Deters: [FontAwesomeFX](https://bitbucket.org/Jerady/fontawesomefx)
11
  * Apache Tika Team: [Apache Tika](https://tika.apache.org/)
11
  * Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet)
1212
A src/main/java/com/scrivenvar/AbstractPane.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;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.Options;
32
import java.util.prefs.Preferences;
33
import org.tbee.javafx.scene.layout.fxml.MigPane;
34
35
/**
36
 * Provides options to all subclasses.
37
 *
38
 * @author White Magic Software, Ltd.
39
 */
40
public abstract class AbstractPane extends MigPane {
41
42
  private final Options options = Services.load( Options.class );
43
44
  protected Options getOptions() {
45
    return this.options;
46
  }
47
  
48
  protected Preferences getState() {
49
    return getOptions().getState();
50
  }
51
}
152
M src/main/java/com/scrivenvar/Constants.java
2828
package com.scrivenvar;
2929
30
import com.scrivenvar.service.Settings;
31
3032
/**
3133
 * @author White Magic Software, Ltd.
3234
 */
3335
public class Constants {
36
37
  private static final Settings SETTINGS = Services.load( Settings.class );
3438
3539
  /**
3640
   * Prevent instantiation.
3741
   */
3842
  private Constants() {
3943
  }
40
  
41
  public static final String BUNDLE_NAME = "com.scrivenvar.messages";
44
45
  private static String get( final String key ) {
46
    return SETTINGS.getSetting( key, "" );
47
  }
48
49
  // Bootstrapping...
4250
  public static final String SETTINGS_NAME = "/com/scrivenvar/settings.properties";
4351
44
  public static final String STYLESHEET_PREVIEW = "com/scrivenvar/scene.css";
45
  public static final String STYLESHEET_EDITOR = "com/scrivenvar/editor/Markdown.css";
52
  public static final String APP_BUNDLE_NAME = get( "application.messages" );
4653
47
  public static final String LOGO_32 = "com/scrivenvar/logo32.png";
48
  public static final String LOGO_16 = "com/scrivenvar/logo16.png";
49
  public static final String LOGO_128 = "com/scrivenvar/logo128.png";
50
  public static final String LOGO_256 = "com/scrivenvar/logo256.png";
51
  public static final String LOGO_512 = "com/scrivenvar/logo512.png";
52
  
53
  /**
54
   * Separates YAML variable nodes (e.g., the dots in <code>$root.node.var$</code>).
55
   */
56
  public static final String SEPARATOR = ".";
57
  
58
  public static final String CARET_POSITION = "CARETPOSITION";
59
  public static final String MD_CARET_POSITION = "${" + CARET_POSITION + "}";
60
  public static final String XML_CARET_POSITION = "<![CDATA[" + MD_CARET_POSITION + "]]>";
54
  public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" );
55
  public static final String STYLESHEET_MARKDOWN = get( "file.stylesheet.markdown" );
56
  public static final String STYLESHEET_PREVIEW = get( "file.stylesheet.preview" );
57
58
  public static final String FILE_LOGO_16 = get( "file.logo.16" );
59
  public static final String FILE_LOGO_32 = get( "file.logo.32" );
60
  public static final String FILE_LOGO_128 = get( "file.logo.128" );
61
  public static final String FILE_LOGO_256 = get( "file.logo.256" );
62
  public static final String FILE_LOGO_512 = get( "file.logo.512" );
63
64
  public static final String CARET_POSITION_BASE = get( "caret.token.base" );
65
  public static final String CARET_POSITION_MD = get( "caret.token.markdown" );
66
  public static final String CARET_POSITION_XML = get( "caret.token.xml" );
67
  public static final String CARET_POSITION_HTML = get( "caret.token.html" );
68
69
  public static final String PREFS_ROOT = get( "preferences.root" );
70
  public static final String PREFS_ROOT_STATE = get( "preferences.root.state" );
71
  public static final String PREFS_ROOT_OPTIONS = get( "preferences.root.options" );
6172
}
6273
M src/main/java/com/scrivenvar/FileEditorTab.java
2626
package com.scrivenvar;
2727
28
import com.scrivenvar.editor.EditorPane;
29
import com.scrivenvar.editor.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
    setUserData( this );
81
82
    this.modified.addListener( (observable, oldPath, newPath) -> updateTab() );
83
    updateTab();
84
85
    setOnSelectionChanged( e -> {
86
      if( isSelected() ) {
87
        Platform.runLater( () -> activated() );
88
      }
89
    } );
90
  }
91
92
  private void updateTab() {
93
    setText( getTabTitle() );
94
    setGraphic( getModifiedMark() );
95
    setTooltip( getTabTooltip() );
96
  }
97
98
  /**
99
   * Returns the base filename (without the directory names).
100
   *
101
   * @return The untitled text if the path hasn't been set.
102
   */
103
  private String getTabTitle() {
104
    final Path filePath = getPath();
105
106
    return (filePath == null)
107
      ? Messages.get( "FileEditor.untitled" )
108
      : filePath.getFileName().toString();
109
  }
110
111
  /**
112
   * Returns the full filename represented by the path.
113
   *
114
   * @return The untitled text if the path hasn't been set.
115
   */
116
  private Tooltip getTabTooltip() {
117
    final Path filePath = getPath();
118
119
    return (filePath == null)
120
      ? null
121
      : new Tooltip( filePath.toString() );
122
  }
123
124
  /**
125
   * Returns a marker to indicate whether the file has been modified.
126
   *
127
   * @return "*" when the file has changed; otherwise null.
128
   */
129
  private Text getModifiedMark() {
130
    return isModified() ? new Text( "*" ) : null;
131
  }
132
133
  /**
134
   * Called when the user switches tab.
135
   */
136
  private void activated() {
137
    // Tab is closed or no longer active.
138
    if( getTabPane() == null || !isSelected() ) {
139
      return;
140
    }
141
142
    // Switch to the tab without loading if the contents are already in memory.
143
    if( getContent() != null ) {
144
      getEditorPane().requestFocus();
145
      return;
146
    }
147
148
    // Load the text and update the preview before the undo manager.
149
    load();
150
151
    // Track undo requests (*must* be called after load).
152
    initUndoManager();
153
    initLayout();
154
    initFocus();
155
  }
156
157
  private void initLayout() {
158
    setContent( getScrollPane() );
159
  }
160
161
  private Node getScrollPane() {
162
    return getEditorPane().getScrollPane();
163
  }
164
165
  private void initFocus() {
166
    getEditorPane().requestFocus();
167
  }
168
169
  private void initUndoManager() {
170
    final UndoManager undoManager = getUndoManager();
171
172
    // Clear undo history after first load.
173
    undoManager.forgetHistory();
174
175
    // Bind the editor undo manager to the properties.
176
    modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
177
    canUndo.bind( undoManager.undoAvailableProperty() );
178
    canRedo.bind( undoManager.redoAvailableProperty() );
179
  }
180
181
  /**
182
   * Returns the index into the text where the caret blinks happily away.
183
   *
184
   * @return A number from 0 to the editor's document text length.
185
   */
186
  public int getCaretPosition() {
187
    return getEditorPane().getEditor().getCaretPosition();
188
  }
189
190
  /**
191
   * Returns true if the given path exactly matches this tab's path.
192
   *
193
   * @param check The path to compare against.
194
   *
195
   * @return true The paths are the same.
196
   */
197
  public boolean isPath( final Path check ) {
198
    final Path filePath = getPath();
199
200
    return filePath == null ? false : filePath.equals( check );
201
  }
202
203
  /**
204
   * Reads the entire file contents from the path associated with this tab.
205
   */
206
  private void load() {
207
    final Path filePath = getPath();
208
209
    if( filePath != null ) {
210
      try {
211
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
212
      } catch( Exception ex ) {
213
        alert(
214
          "FileEditor.loadFailed.title", "FileEditor.loadFailed.message", ex
215
        );
216
      }
217
    }
218
  }
219
220
  /**
221
   * Saves the entire file contents from the path associated with this tab.
222
   *
223
   * @return true The file has been saved.
224
   */
225
  public boolean save() {
226
    try {
227
      Files.write( getPath(), asBytes( getEditorPane().getText() ) );
228
      getEditorPane().getUndoManager().mark();
229
      return true;
230
    } catch( Exception ex ) {
231
      return alert(
232
        "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
233
      );
234
    }
235
  }
236
237
  /**
238
   * Creates an alert dialog and waits for it to close.
239
   *
240
   * @param titleKey Resource bundle key for the alert dialog title.
241
   * @param messageKey Resource bundle key for the alert dialog message.
242
   * @param e The unexpected happening.
243
   *
244
   * @return false
245
   */
246
  private boolean alert(
247
    final String titleKey, final String messageKey, final Exception e ) {
248
    final AlertService service = getAlertService();
249
250
    final AlertMessage message = service.createAlertMessage(
251
      Messages.get( titleKey ),
252
      Messages.get( messageKey ),
253
      getPath(),
254
      e.getMessage()
255
    );
256
257
    service.createAlertError( message ).showAndWait();
258
    return false;
259
  }
260
261
  /**
262
   * Returns a best guess at the file encoding. If the encoding could not be
263
   * detected, this will return the default charset for the JVM.
264
   *
265
   * @param bytes The bytes to perform character encoding detection.
266
   *
267
   * @return The character encoding.
268
   */
269
  private Charset detectEncoding( final byte[] bytes ) {
270
    final UniversalDetector detector = new UniversalDetector( null );
271
    detector.handleData( bytes, 0, bytes.length );
272
    detector.dataEnd();
273
274
    final String charset = detector.getDetectedCharset();
275
    final Charset charEncoding = charset == null
276
      ? Charset.defaultCharset()
277
      : Charset.forName( charset.toUpperCase( ENGLISH ) );
278
279
    detector.reset();
280
281
    return charEncoding;
282
  }
283
284
  /**
285
   * Converts the given string to an array of bytes using the encoding that was
286
   * originally detected (if any) and associated with this file.
287
   *
288
   * @param text The text to convert into the original file encoding.
289
   *
290
   * @return A series of bytes ready for writing to a file.
291
   */
292
  private byte[] asBytes( final String text ) {
293
    return text.getBytes( getEncoding() );
294
  }
295
296
  /**
297
   * Converts the given bytes into a Java String. This will call setEncoding
298
   * with the encoding detected by the CharsetDetector.
299
   *
300
   * @param text The text of unknown character encoding.
301
   *
302
   * @return The text, in its auto-detected encoding, as a String.
303
   */
304
  private String asString( final byte[] text ) {
305
    setEncoding( detectEncoding( text ) );
306
    return new String( text, getEncoding() );
307
  }
308
309
  Path getPath() {
310
    return this.path;
311
  }
312
313
  void setPath( final Path path ) {
314
    this.path = path;
315
  }
316
317
  public boolean isModified() {
318
    return this.modified.get();
319
  }
320
321
  ReadOnlyBooleanProperty modifiedProperty() {
322
    return this.modified.getReadOnlyProperty();
323
  }
324
325
  BooleanProperty canUndoProperty() {
326
    return this.canUndo;
327
  }
328
329
  BooleanProperty canRedoProperty() {
330
    return this.canRedo;
331
  }
332
333
  private UndoManager getUndoManager() {
334
    return getEditorPane().getUndoManager();
335
  }
336
337
  /**
338
   * Forwards the request to the editor pane.
339
   *
340
   * @param <T> The type of event listener to add.
341
   * @param <U> The type of consumer to add.
342
   * @param event The event that should trigger updates to the listener.
343
   * @param consumer The listener to receive update events.
344
   */
345
  public <T extends Event, U extends T> void addEventListener(
346
    final EventPattern<? super T, ? extends U> event,
347
    final Consumer<? super U> consumer ) {
348
    getEditorPane().addEventListener( event, consumer );
349
  }
350
351
  /**
352
   * Forwards to the editor pane's listeners for keyboard events.
353
   *
354
   * @param map The new input map to replace the existing keyboard listener.
355
   */
356
  public void addEventListener( final InputMap<InputEvent> map ) {
357
    getEditorPane().addEventListener( map );
358
  }
359
360
  /**
361
   * Forwards to the editor pane's listeners for keyboard events.
362
   *
363
   * @param map The existing input map to remove from the keyboard listeners.
364
   */
365
  public void removeEventListener( final InputMap<InputEvent> map ) {
366
    getEditorPane().removeEventListener( map );
367
  }
368
369
  /**
370
   * Forwards to the editor pane's listeners for text change events.
371
   *
372
   * @param listener The listener to notify when the text changes.
373
   */
374
  public void addTextChangeListener( final ChangeListener<String> listener ) {
375
    getEditorPane().addTextChangeListener( listener );
376
  }
377
  
378
  /**
379
   * Forwards to the editor pane's listeners for paragraph change events.
380
   *
381
   * @param listener The listener to notify when the caret changes paragraphs.
382
   */
383
  public void addCaretParagraphListener( final ChangeListener<Integer> listener){
384
    getEditorPane().addCaretParagraphListener( listener );
385
  }
386
  
387
  /**
388
   * Delegates the request to the editor pane.
389
   *
390
   * @return The text to process.
391
   */
392
  public String getEditorText() {
393
    return getEditorPane().getText();
394
  }
395
396
  /**
397
   * Returns the editor pane, or creates one if it doesn't yet exist.
398
   *
399
   * @return The editor pane, never null.
400
   */
401
  protected EditorPane getEditorPane() {
402
    if( this.editorPane == null ) {
403
      this.editorPane = new MarkdownEditorPane();
404
    }
405
406
    return this.editorPane;
407
  }
408
409
  private AlertService getAlertService() {
410
    return this.alertService;
411
  }
412
413
  private Options getOptions() {
414
    return this.options;
415
  }
416
417
  private Charset getEncoding() {
418
    return this.encoding;
419
  }
420
421
  private void setEncoding( final Charset encoding ) {
422
    this.encoding = encoding;
28
import com.scrivenvar.editors.EditorPane;
29
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
  protected 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;
414
  }
415
416
  private Charset getEncoding() {
417
    return this.encoding;
418
  }
419
420
  private void setEncoding( final Charset encoding ) {
421
    this.encoding = encoding;
422
  }
423
424
  /**
425
   * Returns the tab title, without any modified indicators.
426
   *
427
   * @return The tab title.
428
   */
429
  @Override
430
  public String toString() {
431
    return getTabTitle();
423432
  }
424433
}
M src/main/java/com/scrivenvar/FileEditorTabPane.java
2828
package com.scrivenvar;
2929
30
import static com.scrivenvar.Messages.get;
31
import com.scrivenvar.predicates.files.FileTypePredicate;
32
import com.scrivenvar.service.Options;
33
import com.scrivenvar.service.Settings;
34
import com.scrivenvar.service.events.AlertMessage;
35
import com.scrivenvar.service.events.AlertService;
36
import static com.scrivenvar.service.events.AlertService.NO;
37
import static com.scrivenvar.service.events.AlertService.YES;
38
import com.scrivenvar.util.Utils;
39
import java.io.File;
40
import java.nio.file.Path;
41
import java.util.ArrayList;
42
import java.util.List;
43
import java.util.function.Consumer;
44
import java.util.prefs.Preferences;
45
import java.util.stream.Collectors;
46
import javafx.beans.property.ReadOnlyBooleanProperty;
47
import javafx.beans.property.ReadOnlyBooleanWrapper;
48
import javafx.beans.property.ReadOnlyObjectProperty;
49
import javafx.beans.property.ReadOnlyObjectWrapper;
50
import javafx.beans.value.ChangeListener;
51
import javafx.beans.value.ObservableValue;
52
import javafx.collections.ListChangeListener;
53
import javafx.collections.ObservableList;
54
import javafx.event.Event;
55
import javafx.scene.Node;
56
import javafx.scene.control.Alert;
57
import javafx.scene.control.ButtonType;
58
import javafx.scene.control.Tab;
59
import javafx.scene.control.TabPane;
60
import javafx.scene.control.TabPane.TabClosingPolicy;
61
import javafx.scene.input.InputEvent;
62
import javafx.stage.FileChooser;
63
import javafx.stage.FileChooser.ExtensionFilter;
64
import javafx.stage.Window;
65
import org.fxmisc.richtext.StyledTextArea;
66
import org.fxmisc.wellbehaved.event.EventPattern;
67
import org.fxmisc.wellbehaved.event.InputMap;
68
69
/**
70
 * Tab pane for file editors.
71
 *
72
 * @author Karl Tauber and White Magic Software, Ltd.
73
 */
74
public final class FileEditorTabPane extends TabPane {
75
  
76
  private final static String FILTER_PREFIX = "Dialog.file.choose.filter";
77
  
78
  private final Options options = Services.load( Options.class );
79
  private final Settings settings = Services.load( Settings.class );
80
  private final AlertService alertService = Services.load( AlertService.class );
81
  
82
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
83
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
84
  
85
  public FileEditorTabPane() {
86
    final ObservableList<Tab> tabs = getTabs();
87
    
88
    setFocusTraversable( false );
89
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
90
    
91
    addTabChangeListener( (ObservableValue<? extends Tab> tabPane,
92
      final Tab oldTab, final Tab newTab) -> {
93
      if( newTab != null ) {
94
        activeFileEditor.set( (FileEditorTab)newTab.getUserData() );
95
      }
96
    } );
97
    
98
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
99
      for( final Tab tab : tabs ) {
100
        if( ((FileEditorTab)tab.getUserData()).isModified() ) {
101
          this.anyFileEditorModified.set( true );
102
          break;
103
        }
104
      }
105
    };
106
    
107
    tabs.addListener( (ListChangeListener<Tab>)change -> {
108
      while( change.next() ) {
109
        if( change.wasAdded() ) {
110
          change.getAddedSubList().stream().forEach( (tab) -> {
111
            ((FileEditorTab)tab.getUserData()).modifiedProperty().addListener( modifiedListener );
112
          } );
113
        } else if( change.wasRemoved() ) {
114
          change.getRemoved().stream().forEach( (tab) -> {
115
            ((FileEditorTab)tab.getUserData()).modifiedProperty().removeListener( modifiedListener );
116
          } );
117
        }
118
      }
119
120
      // Changes in the tabs may also change anyFileEditorModified property
121
      // (e.g. closed modified file)
122
      modifiedListener.changed( null, null, null );
123
    } );
124
  }
125
  
126
  public <T extends Event, U extends T> void addEventListener(
127
    final EventPattern<? super T, ? extends U> event,
128
    final Consumer<? super U> consumer ) {
129
    getActiveFileEditor().addEventListener( event, consumer );
130
  }
131
132
  /**
133
   * Delegates to the active file editor pane, and, ultimately, to its text
134
   * area.
135
   *
136
   * @param map The map of methods to events.
137
   */
138
  public void addEventListener( final InputMap<InputEvent> map ) {
139
    getActiveFileEditor().addEventListener( map );
140
  }
141
142
  /**
143
   * Remove a keyboard event listener from the active file editor.
144
   *
145
   * @param map The keyboard events to remove.
146
   */
147
  public void removeEventListener( final InputMap<InputEvent> map ) {
148
    getActiveFileEditor().removeEventListener( map );
149
  }
150
151
  /**
152
   * Allows observers to be notified when the current file editor tab changes.
153
   *
154
   * @param listener The listener to notify of tab change events.
155
   */
156
  public void addTabChangeListener( final ChangeListener<Tab> listener ) {
157
    // Observe the tab so that when a new tab is opened or selected,
158
    // a notification is kicked off.
159
    getSelectionModel().selectedItemProperty().addListener( listener );
160
  }
161
  
162
  /**
163
   * Allows clients to manipulate the editor content directly.
164
   *
165
   * @return The text area for the active file editor.
166
   */
167
  public StyledTextArea getEditor() {
168
    return getActiveFileEditor().getEditorPane().getEditor();
169
  }
170
  
171
  public FileEditorTab getActiveFileEditor() {
172
    return this.activeFileEditor.get();
173
  }
174
  
175
  ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
176
    return this.activeFileEditor.getReadOnlyProperty();
177
  }
178
  
179
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
180
    return this.anyFileEditorModified.getReadOnlyProperty();
181
  }
182
  
183
  private FileEditorTab createFileEditor( final Path path ) {
184
    final FileEditorTab tab = new FileEditorTab( path );
185
    
186
    tab.setOnCloseRequest( e -> {
187
      if( !canCloseEditor( tab ) ) {
188
        e.consume();
189
      }
190
    } );
191
    
192
    return tab;
193
  }
194
195
  Node getNode() {
196
    return this;
197
  }
198
199
  /**
200
   * Called when the user selects New from the File menu.
201
   *
202
   * @return The newly added tab.
203
   */
204
  FileEditorTab newEditor() {
205
    final FileEditorTab tab = createFileEditor( null );
206
    
207
    getTabs().add( tab );
208
    getSelectionModel().select( tab );
209
    return tab;
210
  }
211
  
212
  List<FileEditorTab> openFileDialog() {
213
    final FileChooser dialog
214
      = createFileChooser( get( "Dialog.file.choose.open.title" ) );
215
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
216
    
217
    return (files != null && !files.isEmpty())
218
      ? openFiles( files )
219
      : new ArrayList<>();
220
  }
221
222
  /**
223
   * Opens the files into new editors, unless one of those files was a
224
   * definition file. The definition file is loaded into the definition pane,
225
   * but only the first one selected (multiple definition files will result in a
226
   * warning).
227
   *
228
   * @param files The list of non-definition files that the were requested to
229
   * open.
230
   *
231
   * @return A list of files that can be opened in text editors.
232
   */
233
  private List<FileEditorTab> openFiles( final List<File> files ) {
234
    final List<FileEditorTab> openedEditors = new ArrayList<>();
235
    
236
    final FileTypePredicate predicate
237
      = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() );
238
239
    // The user might have opened muliple definitions files. These will
240
    // be discarded from the text editable files.
241
    final List<File> definitions
242
      = files.stream().filter( predicate ).collect( Collectors.toList() );
243
244
    // Create a modifiable list to remove any definition files that were
245
    // opened.
246
    final List<File> editors = new ArrayList<>( files );
247
    editors.removeAll( definitions );
248
249
    // If there are any editor-friendly files opened (e.g,. Markdown, XML), then
250
    // open them up in new tabs.
251
    if( editors.size() > 0 ) {
252
      saveLastDirectory( editors.get( 0 ) );
253
      openedEditors.addAll( openEditors( editors, 0 ) );
254
    }
255
    
256
    if( definitions.size() > 0 ) {
257
      openDefinition( definitions.get( 0 ) );
258
    }
259
    
260
    return openedEditors;
261
  }
262
  
263
  private List<FileEditorTab> openEditors( final List<File> files, final int activeIndex ) {
264
    final int fileTally = files.size();
265
    final List<FileEditorTab> editors = new ArrayList<>( fileTally );
266
    final List<Tab> tabs = getTabs();
267
268
    // Close single unmodified "Untitled" tab.
269
    if( tabs.size() == 1 ) {
270
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ).getUserData());
271
      
272
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
273
        closeEditor( fileEditor, false );
274
      }
275
    }
276
    
277
    for( int i = 0; i < fileTally; i++ ) {
278
      final Path path = files.get( i ).toPath();
279
280
      // Check whether file is already opened.
281
      FileEditorTab fileEditor = findEditor( path );
282
      
283
      if( fileEditor == null ) {
284
        fileEditor = createFileEditor( path );
285
        getTabs().add( fileEditor );
286
        editors.add( fileEditor );
287
      }
288
289
      // Select first file.
290
      if( i == activeIndex ) {
291
        getSelectionModel().select( fileEditor );
292
      }
293
    }
294
    
295
    return editors;
296
  }
297
298
  /**
299
   * Called when the user has opened a definition file (using the file open
300
   * dialog box). This will replace the current set of definitions for the
301
   * active tab.
302
   *
303
   * @param definition The file to open.
304
   */
305
  private void openDefinition( final File definition ) {
306
    System.out.println( "open definition file: " + definition.toString() );
307
  }
308
  
309
  boolean saveEditor( final FileEditorTab fileEditor ) {
310
    if( fileEditor == null || !fileEditor.isModified() ) {
311
      return true;
312
    }
313
    
314
    if( fileEditor.getPath() == null ) {
315
      getSelectionModel().select( fileEditor );
316
      
317
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
318
      final File file = fileChooser.showSaveDialog( getWindow() );
319
      if( file == null ) {
320
        return false;
321
      }
322
      
323
      saveLastDirectory( file );
324
      fileEditor.setPath( file.toPath() );
325
    }
326
    
327
    return fileEditor.save();
328
  }
329
  
330
  boolean saveAllEditors() {
331
    boolean success = true;
332
    
333
    for( FileEditorTab fileEditor : getAllEditors() ) {
334
      if( !saveEditor( fileEditor ) ) {
335
        success = false;
336
      }
337
    }
338
    
339
    return success;
340
  }
341
  
342
  boolean canCloseEditor( final FileEditorTab tab ) {
343
    if( !tab.isModified() ) {
344
      return true;
345
    }
346
    
347
    final AlertMessage message = getAlertService().createAlertMessage(
348
      Messages.get( "Alert.file.close.title" ),
349
      Messages.get( "Alert.file.close.text" ),
350
      tab.getText()
351
    );
352
    
353
    final Alert alert = getAlertService().createAlertConfirmation( message );
354
    final ButtonType response = alert.showAndWait().get();
355
    
356
    return response == YES ? saveEditor( tab ) : response == NO;
357
  }
358
  
359
  private AlertService getAlertService() {
360
    return this.alertService;
361
  }
362
  
363
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
364
    if( fileEditor == null ) {
365
      return true;
366
    }
367
    
368
    final Tab tab = fileEditor;
369
    
370
    if( save ) {
371
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
372
      Event.fireEvent( tab, event );
373
      
374
      if( event.isConsumed() ) {
375
        return false;
376
      }
377
    }
378
    
379
    getTabs().remove( tab );
380
    
381
    if( tab.getOnClosed() != null ) {
382
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
383
    }
384
    
385
    return true;
386
  }
387
  
388
  boolean closeAllEditors() {
389
    final FileEditorTab[] allEditors = getAllEditors();
390
    final FileEditorTab activeEditor = getActiveFileEditor();
391
392
    // try to save active tab first because in case the user decides to cancel,
393
    // then it stays active
394
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
395
      return false;
396
    }
397
398
    // This should be called any time a tab changes.
399
    persistPreferences();
400
401
    // save modified tabs
402
    for( int i = 0; i < allEditors.length; i++ ) {
403
      final FileEditorTab fileEditor = allEditors[ i ];
404
      
405
      if( fileEditor == activeEditor ) {
406
        continue;
407
      }
408
      
409
      if( fileEditor.isModified() ) {
410
        // activate the modified tab to make its modified content visible to the user
411
        getSelectionModel().select( i );
412
        
413
        if( !canCloseEditor( fileEditor ) ) {
414
          return false;
415
        }
416
      }
417
    }
418
419
    // Close all tabs.
420
    for( final FileEditorTab fileEditor : allEditors ) {
421
      if( !closeEditor( fileEditor, false ) ) {
422
        return false;
423
      }
424
    }
425
    
426
    return getTabs().isEmpty();
427
  }
428
  
429
  private FileEditorTab[] getAllEditors() {
430
    final ObservableList<Tab> tabs = getTabs();
431
    final int length = tabs.size();
432
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
433
    
434
    for( int i = 0; i < length; i++ ) {
435
      allEditors[ i ] = (FileEditorTab)tabs.get( i ).getUserData();
436
    }
437
    
438
    return allEditors;
439
  }
440
441
  /**
442
   * Returns the file editor tab that has the given path.
443
   *
444
   * @return null No file editor tab for the given path was found.
445
   */
446
  private FileEditorTab findEditor( final Path path ) {
447
    for( final Tab tab : getTabs() ) {
448
      final FileEditorTab fileEditor = (FileEditorTab)tab;
449
      
450
      if( fileEditor.isPath( path ) ) {
451
        return fileEditor;
452
      }
453
    }
454
    
455
    return null;
456
  }
457
  
458
  private FileChooser createFileChooser( String title ) {
459
    final FileChooser fileChooser = new FileChooser();
460
    
461
    fileChooser.setTitle( title );
462
    fileChooser.getExtensionFilters().addAll(
463
      createExtensionFilters() );
464
    
465
    final String lastDirectory = getState().get( "lastDirectory", null );
466
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
467
    
468
    if( !file.isDirectory() ) {
469
      file = new File( "." );
470
    }
471
    
472
    fileChooser.setInitialDirectory( file );
473
    return fileChooser;
474
  }
475
  
476
  private List<ExtensionFilter> createExtensionFilters() {
477
    final List<ExtensionFilter> list = new ArrayList<>();
478
479
    // TODO: Return a list of all properties that match the filter prefix.
480
    // This will allow dynamic filters to be added and removed just by
481
    // updating the properties file.
482
    list.add( createExtensionFilter( "markdown" ) );
483
    list.add( createExtensionFilter( "definition" ) );
484
    list.add( createExtensionFilter( "xml" ) );
485
    list.add( createExtensionFilter( "all" ) );
486
    return list;
487
  }
488
  
489
  private ExtensionFilter createExtensionFilter( final String filetype ) {
490
    final String tKey = String.format( "%s.title.%s", FILTER_PREFIX, filetype );
491
    final String eKey = String.format( "%s.ext.%s", FILTER_PREFIX, filetype );
492
    
493
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
494
  }
495
  
496
  private List<String> getExtensions( final String key ) {
497
    return getStringSettingList( key );
498
  }
499
  
500
  private List<String> getStringSettingList( String key ) {
501
    return getStringSettingList( key, null );
502
  }
503
  
504
  private List<String> getStringSettingList( String key, List<String> values ) {
505
    return getSettings().getStringSettingList( key, values );
506
  }
507
  
508
  private void saveLastDirectory( final File file ) {
509
    getState().put( "lastDirectory", file.getParent() );
510
  }
511
  
512
  public void restorePreferences() {
513
    int activeIndex = 0;
514
    
515
    final Preferences preferences = getState();
516
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
517
    final String activeFileName = preferences.get( "activeFile", null );
518
    
519
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
520
    
521
    for( final String fileName : fileNames ) {
522
      final File file = new File( fileName );
523
      
524
      if( file.exists() ) {
525
        files.add( file );
526
        
527
        if( fileName.equals( activeFileName ) ) {
528
          activeIndex = files.size() - 1;
529
        }
530
      }
531
    }
532
    
533
    if( files.isEmpty() ) {
534
      newEditor();
535
      return;
536
    }
537
    
538
    openEditors( files, activeIndex );
539
  }
540
  
541
  public void persistPreferences() {
542
    final ObservableList<Tab> allEditors = getTabs();
543
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
544
    
545
    for( final Tab tab : allEditors ) {
546
      final FileEditorTab fileEditor = (FileEditorTab)tab;
547
      
548
      if( fileEditor.getPath() != null ) {
549
        fileNames.add( fileEditor.getPath().toString() );
550
      }
551
    }
552
    
553
    final Preferences preferences = getState();
554
    Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
555
    
556
    final FileEditorTab activeEditor = getActiveFileEditor();
557
    
558
    if( activeEditor != null && activeEditor.getPath() != null ) {
559
      preferences.put( "activeFile", activeEditor.getPath().toString() );
560
    } else {
561
      preferences.remove( "activeFile" );
562
    }
563
  }
564
  
565
  private Settings getSettings() {
566
    return this.settings;
567
  }
568
  
569
  protected Options getOptions() {
570
    return this.options;
571
  }
572
  
573
  private Window getWindow() {
574
    return getScene().getWindow();
575
  }
576
  
577
  protected Preferences getState() {
578
    return getOptions().getState();
30
import com.scrivenvar.predicates.files.FileTypePredicate;
31
import com.scrivenvar.service.Options;
32
import com.scrivenvar.service.Settings;
33
import com.scrivenvar.service.events.AlertMessage;
34
import com.scrivenvar.service.events.AlertService;
35
import static com.scrivenvar.service.events.AlertService.NO;
36
import static com.scrivenvar.service.events.AlertService.YES;
37
import com.scrivenvar.util.Utils;
38
import java.io.File;
39
import java.nio.file.Path;
40
import java.util.ArrayList;
41
import java.util.List;
42
import java.util.function.Consumer;
43
import java.util.prefs.Preferences;
44
import java.util.stream.Collectors;
45
import javafx.beans.property.ReadOnlyBooleanProperty;
46
import javafx.beans.property.ReadOnlyBooleanWrapper;
47
import javafx.beans.property.ReadOnlyObjectProperty;
48
import javafx.beans.property.ReadOnlyObjectWrapper;
49
import javafx.beans.value.ChangeListener;
50
import javafx.beans.value.ObservableValue;
51
import javafx.collections.ListChangeListener;
52
import javafx.collections.ObservableList;
53
import javafx.event.Event;
54
import javafx.scene.Node;
55
import javafx.scene.control.Alert;
56
import javafx.scene.control.ButtonType;
57
import javafx.scene.control.Tab;
58
import javafx.scene.control.TabPane;
59
import javafx.scene.control.TabPane.TabClosingPolicy;
60
import javafx.scene.input.InputEvent;
61
import javafx.stage.FileChooser;
62
import javafx.stage.FileChooser.ExtensionFilter;
63
import javafx.stage.Window;
64
import org.fxmisc.richtext.StyledTextArea;
65
import org.fxmisc.wellbehaved.event.EventPattern;
66
import org.fxmisc.wellbehaved.event.InputMap;
67
import static com.scrivenvar.Messages.get;
68
69
/**
70
 * Tab pane for file editors.
71
 *
72
 * @author Karl Tauber and White Magic Software, Ltd.
73
 */
74
public final class FileEditorTabPane extends TabPane {
75
76
  private final static String FILTER_EXTENSIONS = "filter.file";
77
  private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose.filter";
78
79
  private final Options options = Services.load( Options.class );
80
  private final Settings settings = Services.load( Settings.class );
81
  private final AlertService alertService = Services.load( AlertService.class );
82
83
  private final ReadOnlyObjectWrapper<Path> openDefinition = new ReadOnlyObjectWrapper<>();
84
  private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyBooleanWrapper anyFileEditorModified = new ReadOnlyBooleanWrapper();
86
87
  /**
88
   * Constructs a new file editor tab pane.
89
   */
90
  public FileEditorTabPane() {
91
    final ObservableList<Tab> tabs = getTabs();
92
93
    setFocusTraversable( false );
94
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
95
96
    addTabSelectionListener(
97
      (ObservableValue<? extends Tab> tabPane,
98
        final Tab oldTab, final Tab newTab) -> {
99
100
        if( newTab != null ) {
101
          activeFileEditor.set( (FileEditorTab)newTab );
102
        }
103
      }
104
    );
105
106
    final ChangeListener<Boolean> modifiedListener = (observable, oldValue, newValue) -> {
107
      for( final Tab tab : tabs ) {
108
        if( ((FileEditorTab)tab).isModified() ) {
109
          this.anyFileEditorModified.set( true );
110
          break;
111
        }
112
      }
113
    };
114
115
    tabs.addListener(
116
      (ListChangeListener<Tab>)change -> {
117
        while( change.next() ) {
118
          if( change.wasAdded() ) {
119
            change.getAddedSubList().stream().forEach( (tab) -> {
120
              ((FileEditorTab)tab).modifiedProperty().addListener( modifiedListener );
121
            } );
122
          } else if( change.wasRemoved() ) {
123
            change.getRemoved().stream().forEach( (tab) -> {
124
              ((FileEditorTab)tab).modifiedProperty().removeListener( modifiedListener );
125
            } );
126
          }
127
        }
128
129
        // Changes in the tabs may also change anyFileEditorModified property
130
        // (e.g. closed modified file)
131
        modifiedListener.changed( null, null, null );
132
      }
133
    );
134
  }
135
136
  /**
137
   * Delegates to the active file editor.
138
   *
139
   * @param <T> Event type.
140
   * @param <U> Consumer type.
141
   * @param event Event to pass to the editor.
142
   * @param consumer Consumer to pass to the editor.
143
   */
144
  public <T extends Event, U extends T> void addEventListener(
145
    final EventPattern<? super T, ? extends U> event,
146
    final Consumer<? super U> consumer ) {
147
    getActiveFileEditor().addEventListener( event, consumer );
148
  }
149
150
  /**
151
   * Delegates to the active file editor pane, and, ultimately, to its text
152
   * area.
153
   *
154
   * @param map The map of methods to events.
155
   */
156
  public void addEventListener( final InputMap<InputEvent> map ) {
157
    getActiveFileEditor().addEventListener( map );
158
  }
159
160
  /**
161
   * Remove a keyboard event listener from the active file editor.
162
   *
163
   * @param map The keyboard events to remove.
164
   */
165
  public void removeEventListener( final InputMap<InputEvent> map ) {
166
    getActiveFileEditor().removeEventListener( map );
167
  }
168
169
  /**
170
   * Allows observers to be notified when the current file editor tab changes.
171
   *
172
   * @param listener The listener to notify of tab change events.
173
   */
174
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
175
    // Observe the tab so that when a new tab is opened or selected,
176
    // a notification is kicked off.
177
    getSelectionModel().selectedItemProperty().addListener( listener );
178
  }
179
180
  /**
181
   * Allows clients to manipulate the editor content directly.
182
   *
183
   * @return The text area for the active file editor.
184
   */
185
  public StyledTextArea getEditor() {
186
    return getActiveFileEditor().getEditorPane().getEditor();
187
  }
188
189
  public FileEditorTab getActiveFileEditor() {
190
    return this.activeFileEditor.get();
191
  }
192
193
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
194
    return this.activeFileEditor.getReadOnlyProperty();
195
  }
196
197
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
198
    return this.anyFileEditorModified.getReadOnlyProperty();
199
  }
200
201
  private FileEditorTab createFileEditor( final Path path ) {
202
    final FileEditorTab tab = new FileEditorTab( path );
203
204
    tab.setOnCloseRequest( e -> {
205
      if( !canCloseEditor( tab ) ) {
206
        e.consume();
207
      }
208
    } );
209
210
    return tab;
211
  }
212
213
  /**
214
   * Called when the user selects New from the File menu.
215
   *
216
   * @return The newly added tab.
217
   */
218
  void newEditor() {
219
    final FileEditorTab tab = createFileEditor( null );
220
221
    getTabs().add( tab );
222
    getSelectionModel().select( tab );
223
  }
224
225
  void openFileDialog() {
226
    final String title = get( "Dialog.file.choose.open.title" );
227
    final FileChooser dialog = createFileChooser( title );
228
    openFiles( dialog.showOpenMultipleDialog( getWindow() ) );
229
  }
230
231
  /**
232
   * Opens the files into new editors, unless one of those files was a
233
   * definition file. The definition file is loaded into the definition pane,
234
   * but only the first one selected (multiple definition files will result in a
235
   * warning).
236
   *
237
   * @param files The list of non-definition files that the were requested to
238
   * open.
239
   *
240
   * @return A list of files that can be opened in text editors.
241
   */
242
  private void openFiles( final List<File> files ) {
243
    final FileTypePredicate predicate
244
      = new FileTypePredicate( createExtensionFilter( "definition" ).getExtensions() );
245
246
    // The user might have opened multiple definitions files. These will
247
    // be discarded from the text editable files.
248
    final List<File> definitions
249
      = files.stream().filter( predicate ).collect( Collectors.toList() );
250
251
    // Create a modifiable list to remove any definition files that were
252
    // opened.
253
    final List<File> editors = new ArrayList<>( files );
254
255
    if( editors.size() > 0 ) {
256
      saveLastDirectory( editors.get( 0 ) );
257
    }
258
259
    editors.removeAll( definitions );
260
261
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
262
    if( editors.size() > 0 ) {
263
      openEditors( editors, 0 );
264
    }
265
266
    if( definitions.size() > 0 ) {
267
      openDefinition( definitions.get( 0 ) );
268
    }
269
  }
270
271
  private void openEditors( final List<File> files, final int activeIndex ) {
272
    final int fileTally = files.size();
273
    final List<Tab> tabs = getTabs();
274
275
    // Close single unmodified "Untitled" tab.
276
    if( tabs.size() == 1 ) {
277
      final FileEditorTab fileEditor = (FileEditorTab)(tabs.get( 0 ));
278
279
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
280
        closeEditor( fileEditor, false );
281
      }
282
    }
283
284
    for( int i = 0; i < fileTally; i++ ) {
285
      final Path path = files.get( i ).toPath();
286
287
      FileEditorTab fileEditorTab = findEditor( path );
288
289
      // Only open new files.
290
      if( fileEditorTab == null ) {
291
        fileEditorTab = createFileEditor( path );
292
        getTabs().add( fileEditorTab );
293
      }
294
295
      // Select the first file in the list.
296
      if( i == activeIndex ) {
297
        getSelectionModel().select( fileEditorTab );
298
      }
299
    }
300
  }
301
302
  /**
303
   * Returns a property that changes when a new definition file is opened.
304
   *
305
   * @return The path to a definition file that was opened.
306
   */
307
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
308
    return getOnOpenDefinitionFile().getReadOnlyProperty();
309
  }
310
311
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
312
    return this.openDefinition;
313
  }
314
315
  /**
316
   * Called when the user has opened a definition file (using the file open
317
   * dialog box). This will replace the current set of definitions for the
318
   * active tab.
319
   *
320
   * @param definition The file to open.
321
   */
322
  private void openDefinition( final File definition ) {
323
    // TODO: Prevent reading this file twice when a new text document is opened.
324
    // (might be a matter of checking the value first).
325
    getOnOpenDefinitionFile().set( definition.toPath() );
326
  }
327
328
  boolean saveEditor( final FileEditorTab fileEditor ) {
329
    if( fileEditor == null || !fileEditor.isModified() ) {
330
      return true;
331
    }
332
333
    if( fileEditor.getPath() == null ) {
334
      getSelectionModel().select( fileEditor );
335
336
      final FileChooser fileChooser = createFileChooser( Messages.get( "Dialog.file.choose.save.title" ) );
337
      final File file = fileChooser.showSaveDialog( getWindow() );
338
      if( file == null ) {
339
        return false;
340
      }
341
342
      saveLastDirectory( file );
343
      fileEditor.setPath( file.toPath() );
344
    }
345
346
    return fileEditor.save();
347
  }
348
349
  boolean saveAllEditors() {
350
    boolean success = true;
351
352
    for( FileEditorTab fileEditor : getAllEditors() ) {
353
      if( !saveEditor( fileEditor ) ) {
354
        success = false;
355
      }
356
    }
357
358
    return success;
359
  }
360
361
  /**
362
   * Answers whether the file has had modifications. '
363
   *
364
   * @param tab THe tab to check for modifications.
365
   *
366
   * @return false The file is unmodified.
367
   */
368
  boolean canCloseEditor( final FileEditorTab tab ) {
369
    if( !tab.isModified() ) {
370
      return true;
371
    }
372
373
    final AlertMessage message = getAlertService().createAlertMessage(
374
      Messages.get( "Alert.file.close.title" ),
375
      Messages.get( "Alert.file.close.text" ),
376
      tab.getText()
377
    );
378
379
    final Alert alert = getAlertService().createAlertConfirmation( message );
380
    final ButtonType response = alert.showAndWait().get();
381
382
    return response == YES ? saveEditor( tab ) : response == NO;
383
  }
384
385
  private AlertService getAlertService() {
386
    return this.alertService;
387
  }
388
389
  boolean closeEditor( FileEditorTab fileEditor, boolean save ) {
390
    if( fileEditor == null ) {
391
      return true;
392
    }
393
394
    final Tab tab = fileEditor;
395
396
    if( save ) {
397
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
398
      Event.fireEvent( tab, event );
399
400
      if( event.isConsumed() ) {
401
        return false;
402
      }
403
    }
404
405
    getTabs().remove( tab );
406
407
    if( tab.getOnClosed() != null ) {
408
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
409
    }
410
411
    return true;
412
  }
413
414
  boolean closeAllEditors() {
415
    final FileEditorTab[] allEditors = getAllEditors();
416
    final FileEditorTab activeEditor = getActiveFileEditor();
417
418
    // try to save active tab first because in case the user decides to cancel,
419
    // then it stays active
420
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
421
      return false;
422
    }
423
424
    // This should be called any time a tab changes.
425
    persistPreferences();
426
427
    // save modified tabs
428
    for( int i = 0; i < allEditors.length; i++ ) {
429
      final FileEditorTab fileEditor = allEditors[ i ];
430
431
      if( fileEditor == activeEditor ) {
432
        continue;
433
      }
434
435
      if( fileEditor.isModified() ) {
436
        // activate the modified tab to make its modified content visible to the user
437
        getSelectionModel().select( i );
438
439
        if( !canCloseEditor( fileEditor ) ) {
440
          return false;
441
        }
442
      }
443
    }
444
445
    // Close all tabs.
446
    for( final FileEditorTab fileEditor : allEditors ) {
447
      if( !closeEditor( fileEditor, false ) ) {
448
        return false;
449
      }
450
    }
451
452
    return getTabs().isEmpty();
453
  }
454
455
  private FileEditorTab[] getAllEditors() {
456
    final ObservableList<Tab> tabs = getTabs();
457
    final int length = tabs.size();
458
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
459
460
    for( int i = 0; i < length; i++ ) {
461
      allEditors[ i ] = (FileEditorTab)tabs.get( i );
462
    }
463
464
    return allEditors;
465
  }
466
467
  /**
468
   * Returns the file editor tab that has the given path.
469
   *
470
   * @return null No file editor tab for the given path was found.
471
   */
472
  private FileEditorTab findEditor( final Path path ) {
473
    for( final Tab tab : getTabs() ) {
474
      final FileEditorTab fileEditor = (FileEditorTab)tab;
475
476
      if( fileEditor.isPath( path ) ) {
477
        return fileEditor;
478
      }
479
    }
480
481
    return null;
482
  }
483
484
  private FileChooser createFileChooser( String title ) {
485
    final FileChooser fileChooser = new FileChooser();
486
487
    fileChooser.setTitle( title );
488
    fileChooser.getExtensionFilters().addAll(
489
      createExtensionFilters() );
490
491
    final String lastDirectory = getState().get( "lastDirectory", null );
492
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
493
494
    if( !file.isDirectory() ) {
495
      file = new File( "." );
496
    }
497
498
    fileChooser.setInitialDirectory( file );
499
    return fileChooser;
500
  }
501
502
  private List<ExtensionFilter> createExtensionFilters() {
503
    final List<ExtensionFilter> list = new ArrayList<>();
504
505
    // TODO: Return a list of all properties that match the filter prefix.
506
    // This will allow dynamic filters to be added and removed just by
507
    // updating the properties file.
508
    list.add( createExtensionFilter( "markdown" ) );
509
    list.add( createExtensionFilter( "definition" ) );
510
    list.add( createExtensionFilter( "xml" ) );
511
    list.add( createExtensionFilter( "all" ) );
512
    return list;
513
  }
514
515
  private ExtensionFilter createExtensionFilter( final String filetype ) {
516
    final String tKey = String.format( "%s.title.%s", FILTER_EXTENSION_TITLES, filetype );
517
    final String eKey = String.format( "%s.ext.%s", FILTER_EXTENSIONS, filetype );
518
519
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
520
  }
521
522
  private List<String> getExtensions( final String key ) {
523
    return getSettings().getStringSettingList( key );
524
  }
525
526
  private void saveLastDirectory( final File file ) {
527
    getState().put( "lastDirectory", file.getParent() );
528
  }
529
530
  public void restorePreferences() {
531
    int activeIndex = 0;
532
533
    final Preferences preferences = getState();
534
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
535
    final String activeFileName = preferences.get( "activeFile", null );
536
537
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
538
539
    for( final String fileName : fileNames ) {
540
      final File file = new File( fileName );
541
542
      if( file.exists() ) {
543
        files.add( file );
544
545
        if( fileName.equals( activeFileName ) ) {
546
          activeIndex = files.size() - 1;
547
        }
548
      }
549
    }
550
551
    if( files.isEmpty() ) {
552
      newEditor();
553
    } else {
554
      openEditors( files, activeIndex );
555
    }
556
  }
557
558
  public void persistPreferences() {
559
    final ObservableList<Tab> allEditors = getTabs();
560
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
561
562
    for( final Tab tab : allEditors ) {
563
      final FileEditorTab fileEditor = (FileEditorTab)tab;
564
      final Path filePath = fileEditor.getPath();
565
566
      if( filePath != null ) {
567
        fileNames.add( filePath.toString() );
568
      }
569
    }
570
571
    final Preferences preferences = getState();
572
    Utils.putPrefsStrings( preferences, "file", fileNames.toArray( new String[ fileNames.size() ] ) );
573
574
    final FileEditorTab activeEditor = getActiveFileEditor();
575
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
576
577
    if( filePath == null ) {
578
      preferences.remove( "activeFile" );
579
    } else {
580
      preferences.put( "activeFile", filePath.toString() );
581
    }
582
  }
583
584
  private Settings getSettings() {
585
    return this.settings;
586
  }
587
588
  protected Options getOptions() {
589
    return this.options;
590
  }
591
592
  private Window getWindow() {
593
    return getScene().getWindow();
594
  }
595
596
  protected Preferences getState() {
597
    return getOptions().getState();
598
  }
599
600
  Node getNode() {
601
    return this;
579602
  }
580603
}
M src/main/java/com/scrivenvar/Main.java
2828
package com.scrivenvar;
2929
30
import static com.scrivenvar.Constants.LOGO_128;
31
import static com.scrivenvar.Constants.LOGO_16;
32
import static com.scrivenvar.Constants.LOGO_256;
33
import static com.scrivenvar.Constants.LOGO_32;
34
import static com.scrivenvar.Constants.LOGO_512;
30
import static com.scrivenvar.Constants.*;
3531
import com.scrivenvar.service.Options;
3632
import com.scrivenvar.service.events.AlertService;
...
7167
    initStage( stage );
7268
    initAlertService();
73
    
69
7470
    stage.show();
7571
  }
...
9389
  private void initStage( Stage stage ) {
9490
    stage.getIcons().addAll(
95
      new Image( LOGO_16 ),
96
      new Image( LOGO_32 ),
97
      new Image( LOGO_128 ),
98
      new Image( LOGO_256 ),
99
      new Image( LOGO_512 ) );
91
      createImage( FILE_LOGO_16 ),
92
      createImage( FILE_LOGO_32 ),
93
      createImage( FILE_LOGO_128 ),
94
      createImage( FILE_LOGO_256 ),
95
      createImage( FILE_LOGO_512 ) );
10096
    stage.setTitle( getApplicationTitle() );
10197
    stage.setScene( getScene() );
...
121117
  public static void showDocument( String uri ) {
122118
    getApplication().getHostServices().showDocument( uri );
119
  }
120
121
  private Image createImage( final String filename ) {
122
    return new Image( filename );
123123
  }
124124
}
M src/main/java/com/scrivenvar/MainWindow.java
2828
package com.scrivenvar;
2929
30
import static com.scrivenvar.Constants.LOGO_32;
31
import static com.scrivenvar.Messages.get;
32
import com.scrivenvar.definition.DefinitionPane;
33
import com.scrivenvar.editor.MarkdownEditorPane;
34
import com.scrivenvar.editor.VariableNameInjector;
35
import com.scrivenvar.preview.HTMLPreviewPane;
36
import com.scrivenvar.processors.HTMLPreviewProcessor;
37
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
38
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
39
import com.scrivenvar.processors.MarkdownProcessor;
40
import com.scrivenvar.processors.Processor;
41
import com.scrivenvar.processors.VariableProcessor;
42
import com.scrivenvar.service.Options;
43
import com.scrivenvar.util.Action;
44
import com.scrivenvar.util.ActionUtils;
45
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
46
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
47
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
48
import com.scrivenvar.yaml.YamlParser;
49
import com.scrivenvar.yaml.YamlTreeAdapter;
50
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
51
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
52
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
66
import java.io.IOException;
67
import java.io.InputStream;
68
import java.util.Map;
69
import java.util.function.Function;
70
import java.util.prefs.Preferences;
71
import javafx.beans.binding.Bindings;
72
import javafx.beans.binding.BooleanBinding;
73
import javafx.beans.property.BooleanProperty;
74
import javafx.beans.property.SimpleBooleanProperty;
75
import javafx.beans.value.ObservableBooleanValue;
76
import javafx.beans.value.ObservableValue;
77
import javafx.collections.ListChangeListener.Change;
78
import javafx.collections.ObservableList;
79
import javafx.event.Event;
80
import javafx.scene.Node;
81
import javafx.scene.Scene;
82
import javafx.scene.control.Alert;
83
import javafx.scene.control.Alert.AlertType;
84
import javafx.scene.control.Menu;
85
import javafx.scene.control.MenuBar;
86
import javafx.scene.control.SplitPane;
87
import javafx.scene.control.Tab;
88
import javafx.scene.control.ToolBar;
89
import javafx.scene.control.TreeView;
90
import javafx.scene.image.Image;
91
import javafx.scene.image.ImageView;
92
import static javafx.scene.input.KeyCode.ESCAPE;
93
import javafx.scene.input.KeyEvent;
94
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
95
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
96
import javafx.scene.layout.BorderPane;
97
import javafx.scene.layout.VBox;
98
import javafx.stage.Window;
99
import javafx.stage.WindowEvent;
100
101
/**
102
 * Main window containing a tab pane in the center for file editors.
103
 *
104
 * @author Karl Tauber and White Magic Software, Ltd.
105
 */
106
public class MainWindow {
107
108
  private final Options options = Services.load( Options.class );
109
110
  private Scene scene;
111
112
  private TreeView<String> treeView;
113
  private DefinitionPane definitionPane;
114
  private FileEditorTabPane fileEditorPane;
115
  private HTMLPreviewPane previewPane;
116
117
  private VariableNameInjector variableNameInjector;
118
119
  private YamlTreeAdapter yamlTreeAdapter;
120
  private YamlParser yamlParser;
121
122
  private MenuBar menuBar;
123
124
  public MainWindow() {
125
    initLayout();
126
    initTabAddedListener();
127
    restorePreferences();
128
    initTabChangeListener();
129
    initVariableNameInjector();
130
  }
131
132
  private void initLayout() {
133
    final SplitPane splitPane = new SplitPane(
134
      getDefinitionPane().getNode(),
135
      getFileEditorPane().getNode(),
136
      getPreviewPane().getNode() );
137
138
    splitPane.setDividerPositions(
139
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
140
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
141
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
142
143
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
144
    final BorderPane borderPane = new BorderPane();
145
    borderPane.setPrefSize( 1024, 800 );
146
    borderPane.setTop( createMenuBar() );
147
    borderPane.setCenter( splitPane );
148
149
    final Scene appScene = new Scene( borderPane );
150
    setScene( appScene );
151
    appScene.getStylesheets().add( Constants.STYLESHEET_PREVIEW );
152
    appScene.windowProperty().addListener(
153
      (observable, oldWindow, newWindow) -> {
154
        newWindow.setOnCloseRequest( e -> {
155
          if( !getFileEditorPane().closeAllEditors() ) {
156
            e.consume();
157
          }
158
        } );
159
160
        // Workaround JavaFX bug: deselect menubar if window loses focus.
161
        newWindow.focusedProperty().addListener(
162
          (obs, oldFocused, newFocused) -> {
163
            if( !newFocused ) {
164
              // Send an ESC key event to the menubar
165
              this.menuBar.fireEvent(
166
                new KeyEvent(
167
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
168
                  false, false, false, false ) );
169
            }
170
          } );
171
      } );
172
  }
173
174
  private void initVariableNameInjector() {
175
    setVariableNameInjector( new VariableNameInjector(
176
      getFileEditorPane(),
177
      getDefinitionPane() )
178
    );
179
  }
180
181
  private Window getWindow() {
182
    return getScene().getWindow();
183
  }
184
185
  public Scene getScene() {
186
    return this.scene;
187
  }
188
189
  private void setScene( Scene scene ) {
190
    this.scene = scene;
191
  }
192
193
  /**
194
   * Creates a boolean property that is bound to another boolean value of the
195
   * active editor.
196
   */
197
  private BooleanProperty createActiveBooleanProperty(
198
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
199
200
    final BooleanProperty b = new SimpleBooleanProperty();
201
    final FileEditorTab tab = getActiveFileEditor();
202
203
    if( tab != null ) {
204
      b.bind( func.apply( tab ) );
205
    }
206
207
    getFileEditorPane().activeFileEditorProperty().addListener(
208
      (observable, oldFileEditor, newFileEditor) -> {
209
        b.unbind();
210
211
        if( newFileEditor != null ) {
212
          b.bind( func.apply( newFileEditor ) );
213
        } else {
214
          b.set( false );
215
        }
216
      } );
217
218
    return b;
219
  }
220
221
  //---- File actions -------------------------------------------------------
222
  private void fileNew() {
223
    getFileEditorPane().newEditor();
224
  }
225
226
  private void fileOpen() {
227
    getFileEditorPane().openFileDialog();
228
  }
229
230
  private void fileClose() {
231
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
232
  }
233
234
  private void fileCloseAll() {
235
    getFileEditorPane().closeAllEditors();
236
  }
237
238
  private void fileSave() {
239
    getFileEditorPane().saveEditor( getActiveFileEditor() );
240
  }
241
242
  private void fileSaveAll() {
243
    getFileEditorPane().saveAllEditors();
244
  }
245
246
  private void fileExit() {
247
    final Window window = getWindow();
248
    Event.fireEvent( window,
249
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
250
  }
251
252
  //---- Help actions -------------------------------------------------------
253
  private void helpAbout() {
254
    Alert alert = new Alert( AlertType.INFORMATION );
255
    alert.setTitle( Messages.get( "Dialog.about.title" ) );
256
    alert.setHeaderText( Messages.get( "Dialog.about.header" ) );
257
    alert.setContentText( Messages.get( "Dialog.about.content" ) );
258
    alert.setGraphic( new ImageView( new Image( LOGO_32 ) ) );
259
    alert.initOwner( getWindow() );
260
261
    alert.showAndWait();
262
  }
263
264
  private FileEditorTabPane getFileEditorPane() {
265
    if( this.fileEditorPane == null ) {
266
      this.fileEditorPane = createFileEditorPane();
267
    }
268
269
    return this.fileEditorPane;
270
  }
271
272
  /**
273
   * Create an editor pane to hold file editor tabs.
274
   *
275
   * @return A new instance, never null.
276
   */
277
  private FileEditorTabPane createFileEditorPane() {
278
    return new FileEditorTabPane();
279
  }
280
281
  /**
282
   * Reloads the preferences from the previous load.
283
   */
284
  private void restorePreferences() {
285
    getFileEditorPane().restorePreferences();
286
  }
287
288
  private void initTabAddedListener() {
289
    final FileEditorTabPane editorPane = getFileEditorPane();
290
291
    // Make sure the text processor kicks off when new files are opened.
292
    final ObservableList<Tab> tabs = editorPane.getTabs();
293
294
    // Update the preview pane on tab changes.
295
    tabs.addListener( (final Change<? extends Tab> change) -> {
296
      while( change.next() ) {
297
        if( change.wasAdded() ) {
298
          // Multiple tabs can be added simultaneously.
299
          for( final Tab newTab : change.getAddedSubList() ) {
300
            final FileEditorTab tab = (FileEditorTab)newTab;
301
302
            initTextChangeListener( tab );
303
            initCaretParagraphListener( tab );
304
            process( tab );
305
          }
306
        }
307
      }
308
    } );
309
  }
310
311
  /**
312
   * Listen for tab changes.
313
   */
314
  private void initTabChangeListener() {
315
    final FileEditorTabPane editorPane = getFileEditorPane();
316
317
    // Update the preview pane changing tabs.
318
    editorPane.addTabChangeListener(
319
      (ObservableValue<? extends Tab> tabPane,
320
        final Tab oldTab, final Tab newTab) -> {
321
322
        final FileEditorTab tab = (FileEditorTab)newTab;
323
324
        if( tab != null ) {
325
          // When a new tab is selected, ensure that the base path to images
326
          // is set correctly.
327
          getPreviewPane().setPath( tab.getPath() );
328
          process( tab );
329
        }
330
      } );
331
  }
332
333
  private void initTextChangeListener( final FileEditorTab tab ) {
334
    tab.addTextChangeListener( (ObservableValue<? extends String> editor,
335
      final String oldValue, final String newValue) -> {
336
      process( tab );
337
    } );
338
  }
339
340
  private void initCaretParagraphListener( final FileEditorTab tab ) {
341
    tab.addCaretParagraphListener( (ObservableValue<? extends Integer> editor,
342
      final Integer oldValue, final Integer newValue) -> {
343
      process( tab );
344
    } );
345
  }
346
347
  /**
348
   * Called whenever the preview pane becomes out of sync with the file editor
349
   * tab. This can be called when the text changes, the caret paragraph changes,
350
   * or the file tab changes.
351
   *
352
   * @param tab The file editor tab that has been changed in some fashion.
353
   */
354
  private void process( final FileEditorTab tab ) {
355
    // TODO: Use a factory based on the filename extension. The default
356
    // extension will be for a markdown file (e.g., on file new).
357
    final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() );
358
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
359
    final Processor<String> mp = new MarkdownProcessor( mcrp );
360
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
361
    final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
362
    
363
    vp.processChain( tab.getEditorText() );
364
  }
365
366
  private MarkdownEditorPane getActiveEditor() {
367
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
368
  }
369
370
  private FileEditorTab getActiveFileEditor() {
371
    return getFileEditorPane().getActiveFileEditor();
372
  }
373
374
  protected DefinitionPane createDefinitionPane() {
375
    return new DefinitionPane( getTreeView() );
376
  }
377
378
  private DefinitionPane getDefinitionPane() {
379
    if( this.definitionPane == null ) {
380
      this.definitionPane = createDefinitionPane();
381
    }
382
383
    return this.definitionPane;
384
  }
385
386
  public MenuBar getMenuBar() {
387
    return this.menuBar;
388
  }
389
390
  public void setMenuBar( MenuBar menuBar ) {
391
    this.menuBar = menuBar;
392
  }
393
394
  public VariableNameInjector getVariableNameInjector() {
395
    return this.variableNameInjector;
396
  }
397
398
  public void setVariableNameInjector( VariableNameInjector variableNameInjector ) {
399
    this.variableNameInjector = variableNameInjector;
400
  }
401
402
  private float getFloat( final String key, final float defaultValue ) {
403
    return getPreferences().getFloat( key, defaultValue );
404
  }
405
406
  private Preferences getPreferences() {
407
    return getOptions().getState();
408
  }
409
410
  private Options getOptions() {
411
    return this.options;
412
  }
413
414
  private synchronized TreeView<String> getTreeView() throws RuntimeException {
415
    if( this.treeView == null ) {
416
      try {
417
        this.treeView = createTreeView();
418
      } catch( IOException ex ) {
419
420
        // TODO: Pop an error message.
421
        throw new RuntimeException( ex );
422
      }
423
    }
424
425
    return this.treeView;
426
  }
427
428
  private InputStream asStream( final String resource ) {
429
    return getClass().getResourceAsStream( resource );
430
  }
431
432
  private TreeView<String> createTreeView() throws IOException {
433
    // TODO: Associate variable file with path to current file.
434
    return getYamlTreeAdapter().adapt(
435
      asStream( "/com/scrivenvar/variables.yaml" ),
436
      get( "Pane.defintion.node.root.title" )
437
    );
438
  }
439
440
  private Map<String, String> getResolvedMap() {
441
    return getYamlParser().createResolvedMap();
442
  }
443
444
  private YamlTreeAdapter getYamlTreeAdapter() {
445
    if( this.yamlTreeAdapter == null ) {
446
      setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
447
    }
448
449
    return this.yamlTreeAdapter;
450
  }
451
452
  private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
453
    this.yamlTreeAdapter = yamlTreeAdapter;
454
  }
455
456
  private YamlParser getYamlParser() {
457
    if( this.yamlParser == null ) {
458
      setYamlParser( new YamlParser() );
459
    }
460
461
    return this.yamlParser;
462
  }
463
464
  private void setYamlParser( final YamlParser yamlParser ) {
465
    this.yamlParser = yamlParser;
466
  }
467
468
  private Node createMenuBar() {
469
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
470
471
    // File actions
472
    Action fileNewAction = new Action( Messages.get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
473
    Action fileOpenAction = new Action( Messages.get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
474
    Action fileCloseAction = new Action( Messages.get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
475
    Action fileCloseAllAction = new Action( Messages.get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
476
    Action fileSaveAction = new Action( Messages.get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
477
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
478
    Action fileSaveAllAction = new Action( Messages.get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
479
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
480
    Action fileExitAction = new Action( Messages.get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
481
482
    // Edit actions
483
    Action editUndoAction = new Action( Messages.get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
484
      e -> getActiveEditor().undo(),
485
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
486
    Action editRedoAction = new Action( Messages.get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
487
      e -> getActiveEditor().redo(),
488
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
489
490
    // Insert actions
491
    Action insertBoldAction = new Action( Messages.get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
492
      e -> getActiveEditor().surroundSelection( "**", "**" ),
493
      activeFileEditorIsNull );
494
    Action insertItalicAction = new Action( Messages.get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
495
      e -> getActiveEditor().surroundSelection( "*", "*" ),
496
      activeFileEditorIsNull );
497
    Action insertStrikethroughAction = new Action( Messages.get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
498
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
499
      activeFileEditorIsNull );
500
    Action insertBlockquoteAction = new Action( Messages.get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
501
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
502
      activeFileEditorIsNull );
503
    Action insertCodeAction = new Action( Messages.get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
504
      e -> getActiveEditor().surroundSelection( "`", "`" ),
505
      activeFileEditorIsNull );
506
    Action insertFencedCodeBlockAction = new Action( Messages.get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
507
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", Messages.get( "Main.menu.insert.fenced_code_block.prompt" ) ),
508
      activeFileEditorIsNull );
509
510
    Action insertLinkAction = new Action( Messages.get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
511
      e -> getActiveEditor().insertLink(),
512
      activeFileEditorIsNull );
513
    Action insertImageAction = new Action( Messages.get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
514
      e -> getActiveEditor().insertImage(),
515
      activeFileEditorIsNull );
516
517
    final Action[] headers = new Action[ 6 ];
518
519
    // Insert header actions (H1 ... H6)
520
    for( int i = 1; i <= 6; i++ ) {
521
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
522
      final String markup = String.format( "\n\n%s ", hashes );
523
      final String text = Messages.get( "Main.menu.insert.header_" + i );
524
      final String accelerator = "Shortcut+" + i;
525
      final String prompt = Messages.get( "Main.menu.insert.header_" + i + ".prompt" );
526
527
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
528
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
529
        activeFileEditorIsNull );
530
    }
531
532
    Action insertUnorderedListAction = new Action( Messages.get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
533
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
534
      activeFileEditorIsNull );
535
    Action insertOrderedListAction = new Action( Messages.get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
536
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
537
      activeFileEditorIsNull );
538
    Action insertHorizontalRuleAction = new Action( Messages.get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
539
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
540
      activeFileEditorIsNull );
541
542
    // Help actions
543
    Action helpAboutAction = new Action( Messages.get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
544
545
    //---- MenuBar ----
546
    Menu fileMenu = ActionUtils.createMenu( Messages.get( "Main.menu.file" ),
547
      fileNewAction,
548
      fileOpenAction,
549
      null,
550
      fileCloseAction,
551
      fileCloseAllAction,
552
      null,
553
      fileSaveAction,
554
      fileSaveAllAction,
555
      null,
556
      fileExitAction );
557
558
    Menu editMenu = ActionUtils.createMenu( Messages.get( "Main.menu.edit" ),
559
      editUndoAction,
560
      editRedoAction );
561
562
    Menu insertMenu = ActionUtils.createMenu( Messages.get( "Main.menu.insert" ),
563
      insertBoldAction,
564
      insertItalicAction,
565
      insertStrikethroughAction,
566
      insertBlockquoteAction,
567
      insertCodeAction,
568
      insertFencedCodeBlockAction,
569
      null,
570
      insertLinkAction,
571
      insertImageAction,
572
      null,
573
      headers[ 0 ],
574
      headers[ 1 ],
575
      headers[ 2 ],
576
      headers[ 3 ],
577
      headers[ 4 ],
578
      headers[ 5 ],
579
      null,
580
      insertUnorderedListAction,
581
      insertOrderedListAction,
582
      insertHorizontalRuleAction );
583
584
    Menu helpMenu = ActionUtils.createMenu( Messages.get( "Main.menu.help" ),
585
      helpAboutAction );
586
587
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
588
589
    //---- ToolBar ----
590
    ToolBar toolBar = ActionUtils.createToolBar(
591
      fileNewAction,
592
      fileOpenAction,
593
      fileSaveAction,
594
      null,
595
      editUndoAction,
596
      editRedoAction,
597
      null,
598
      insertBoldAction,
599
      insertItalicAction,
600
      insertBlockquoteAction,
601
      insertCodeAction,
602
      insertFencedCodeBlockAction,
603
      null,
604
      insertLinkAction,
605
      insertImageAction,
606
      null,
607
      headers[ 0 ],
608
      null,
609
      insertUnorderedListAction,
610
      insertOrderedListAction );
611
612
    return new VBox( menuBar, toolBar );
613
  }
614
615
  private synchronized HTMLPreviewPane getPreviewPane() {
616
    if( this.previewPane == null ) {
617
      this.previewPane = new HTMLPreviewPane();
618
    }
619
620
    return this.previewPane;
621
  }
622
30
import static com.scrivenvar.Constants.FILE_LOGO_32;
31
import static com.scrivenvar.Constants.STYLESHEET_SCENE;
32
import static com.scrivenvar.Messages.get;
33
import com.scrivenvar.definition.DefinitionFactory;
34
import com.scrivenvar.definition.DefinitionPane;
35
import com.scrivenvar.definition.DefinitionSource;
36
import com.scrivenvar.editors.VariableNameInjector;
37
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
38
import com.scrivenvar.preview.HTMLPreviewPane;
39
import com.scrivenvar.processors.HTMLPreviewProcessor;
40
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
41
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
42
import com.scrivenvar.processors.MarkdownProcessor;
43
import com.scrivenvar.processors.Processor;
44
import com.scrivenvar.processors.VariableProcessor;
45
import com.scrivenvar.service.Options;
46
import com.scrivenvar.util.Action;
47
import com.scrivenvar.util.ActionUtils;
48
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
49
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
50
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
51
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
52
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
66
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
67
import java.io.File;
68
import java.nio.file.Path;
69
import java.util.HashMap;
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
103
/**
104
 * Main window containing a tab pane in the center for file editors.
105
 *
106
 * @author Karl Tauber and White Magic Software, Ltd.
107
 */
108
public class MainWindow {
109
110
  private final Options options = Services.load( Options.class );
111
112
  private Scene scene;
113
114
  private DefinitionPane definitionPane;
115
  private FileEditorTabPane fileEditorPane;
116
  private HTMLPreviewPane previewPane;
117
118
  private VariableNameInjector variableNameInjector;
119
120
  private MenuBar menuBar;
121
122
  public MainWindow() {
123
    initLayout();
124
    initOpenDefinitionListener();
125
    initTabAddedListener();
126
    initTabChangeListener();
127
    initPreferences();
128
    initVariableNameInjector();
129
  }
130
131
  /**
132
   * Listen for file editor tab pane to receive an open definition source event.
133
   */
134
  private void initOpenDefinitionListener() {
135
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
136
      (ObservableValue<? extends Path> definitionFile,
137
        final Path oldPath, final Path newPath) -> {
138
        final DefinitionSource ds = createDefinitionSource( newPath );
139
        associate( ds, getActiveFileEditor() );
140
      } );
141
  }
142
143
  /**
144
   * When tabs are added, hook the various change listeners onto the new tab so
145
   * that the preview pane refreshes as necessary.
146
   */
147
  private void initTabAddedListener() {
148
    final FileEditorTabPane editorPane = getFileEditorPane();
149
150
    // Make sure the text processor kicks off when new files are opened.
151
    final ObservableList<Tab> tabs = editorPane.getTabs();
152
153
    // Update the preview pane on tab changes.
154
    tabs.addListener(
155
      (final Change<? extends Tab> change) -> {
156
        while( change.next() ) {
157
          if( change.wasAdded() ) {
158
            // Multiple tabs can be added simultaneously.
159
            for( final Tab newTab : change.getAddedSubList() ) {
160
              final FileEditorTab tab = (FileEditorTab)newTab;
161
162
              initTextChangeListener( tab );
163
              initCaretParagraphListener( 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
  }
177
178
  /**
179
   * Listen for new tab selection events.
180
   */
181
  private void initTabChangeListener() {
182
    final FileEditorTabPane editorPane = getFileEditorPane();
183
184
    // Update the preview pane changing tabs.
185
    editorPane.addTabSelectionListener(
186
      (ObservableValue<? extends Tab> tabPane,
187
        final Tab oldTab, final Tab newTab) -> {
188
189
        // If there was no old tab, then this is a first time load, which
190
        // can be ignored.
191
        if( oldTab != null ) {
192
          if( newTab == null ) {
193
            closeRemainingTab();
194
          } else {
195
            // Synchronize the preview with the edited text.
196
            refreshSelectedTab( (FileEditorTab)newTab );
197
          }
198
        }
199
      }
200
    );
201
  }
202
203
  /**
204
   * Initialize the variable name editor.
205
   */
206
  private void initVariableNameInjector() {
207
    setVariableNameInjector(
208
      new VariableNameInjector( getFileEditorPane(), getDefinitionPane() )
209
    );
210
  }
211
212
  private void initTextChangeListener( final FileEditorTab tab ) {
213
    tab.addTextChangeListener(
214
      (ObservableValue<? extends String> editor,
215
        final String oldValue, final String newValue) -> {
216
        refreshSelectedTab( tab );
217
      }
218
    );
219
  }
220
221
  private void initCaretParagraphListener( final FileEditorTab tab ) {
222
    tab.addCaretParagraphListener(
223
      (ObservableValue<? extends Integer> editor,
224
        final Integer oldValue, final Integer newValue) -> {
225
        refreshSelectedTab( tab );
226
      }
227
    );
228
  }
229
230
  /**
231
   * Called whenever the preview pane becomes out of sync with the file editor
232
   * tab. This can be called when the text changes, the caret paragraph changes,
233
   * or the file tab changes.
234
   *
235
   * @param tab The file editor tab that has been changed in some fashion.
236
   */
237
  private void refreshSelectedTab( final FileEditorTab tab ) {
238
    final HTMLPreviewPane preview = getPreviewPane();
239
    preview.setPath( tab.getPath() );
240
241
    final Processor<String> hpp = new HTMLPreviewProcessor( preview );
242
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
243
    final Processor<String> mp = new MarkdownProcessor( mcrp );
244
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
245
    final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
246
247
    vp.processChain( tab.getEditorText() );
248
  }
249
250
  /**
251
   * TODO: Patch into loading of definition source.
252
   *
253
   * @return
254
   */
255
  private Map<String, String> getResolvedMap() {
256
    return new HashMap<>();
257
  }
258
259
  /**
260
   * TODO: Patch into loading of definition source.
261
   *
262
   * @return
263
   */
264
  private TreeView<String> getTreeView() {
265
    return new TreeView<>();
266
  }
267
268
  /**
269
   * Called when the tab has changed to a new editor to replace the current
270
   * definition pane with the
271
   *
272
   * @param tab Reference to the tab that has the file being edited.
273
   */
274
  private void updateDefinitionPane( final FileEditorTab tab ) {
275
    // Look up the path to the variable definition file associated with the
276
    // given tab.
277
    final Path path = getVariableDefinitionPath( tab.getPath() );
278
    final DefinitionSource ds = createDefinitionSource( path );
279
280
    associate( ds, tab );
281
  }
282
283
  private void associate( final DefinitionSource ds, final FileEditorTab tab ) {
284
    System.out.println( "Associate " + ds + " with " + tab );
285
  }
286
287
  /**
288
   * Searches the persistent settings for the variable definition file that is
289
   * associated with the given path.
290
   *
291
   * @param tabPath The path that may be associated with some variables.
292
   *
293
   * @return A path to the variable definition file for the given document path.
294
   */
295
  private Path getVariableDefinitionPath( final Path tabPath ) {
296
    return new File( "/tmp/variables.yaml" ).toPath();
297
  }
298
299
  /**
300
   * Creates a boolean property that is bound to another boolean value of the
301
   * active editor.
302
   */
303
  private BooleanProperty createActiveBooleanProperty(
304
    final Function<FileEditorTab, ObservableBooleanValue> func ) {
305
306
    final BooleanProperty b = new SimpleBooleanProperty();
307
    final FileEditorTab tab = getActiveFileEditor();
308
309
    if( tab != null ) {
310
      b.bind( func.apply( tab ) );
311
    }
312
313
    getFileEditorPane().activeFileEditorProperty().addListener(
314
      (observable, oldFileEditor, newFileEditor) -> {
315
        b.unbind();
316
317
        if( newFileEditor != null ) {
318
          b.bind( func.apply( newFileEditor ) );
319
        } else {
320
          b.set( false );
321
        }
322
      }
323
    );
324
325
    return b;
326
  }
327
328
  /**
329
   * Called when the last open tab is closed. This clears out the preview pane
330
   * and the definition pane.
331
   */
332
  private void closeRemainingTab() {
333
    getPreviewPane().clear();
334
    getDefinitionPane().clear();
335
  }
336
337
  //---- File actions -------------------------------------------------------
338
  private void fileNew() {
339
    getFileEditorPane().newEditor();
340
  }
341
342
  private void fileOpen() {
343
    getFileEditorPane().openFileDialog();
344
  }
345
346
  private void fileClose() {
347
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
348
  }
349
350
  private void fileCloseAll() {
351
    getFileEditorPane().closeAllEditors();
352
  }
353
354
  private void fileSave() {
355
    getFileEditorPane().saveEditor( getActiveFileEditor() );
356
  }
357
358
  private void fileSaveAll() {
359
    getFileEditorPane().saveAllEditors();
360
  }
361
362
  private void fileExit() {
363
    final Window window = getWindow();
364
    Event.fireEvent( window,
365
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
366
  }
367
368
  //---- Help actions -------------------------------------------------------
369
  private void helpAbout() {
370
    Alert alert = new Alert( AlertType.INFORMATION );
371
    alert.setTitle( get( "Dialog.about.title" ) );
372
    alert.setHeaderText( get( "Dialog.about.header" ) );
373
    alert.setContentText( get( "Dialog.about.content" ) );
374
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
375
    alert.initOwner( getWindow() );
376
377
    alert.showAndWait();
378
  }
379
380
  //---- Convenience accessors ----------------------------------------------
381
  private float getFloat( final String key, final float defaultValue ) {
382
    return getPreferences().getFloat( key, defaultValue );
383
  }
384
385
  private Preferences getPreferences() {
386
    return getOptions().getState();
387
  }
388
389
  private Window getWindow() {
390
    return getScene().getWindow();
391
  }
392
393
  private MarkdownEditorPane getActiveEditor() {
394
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
395
  }
396
397
  private FileEditorTab getActiveFileEditor() {
398
    return getFileEditorPane().getActiveFileEditor();
399
  }
400
401
  //---- Member accessors ---------------------------------------------------
402
  public Scene getScene() {
403
    return this.scene;
404
  }
405
406
  private void setScene( Scene scene ) {
407
    this.scene = scene;
408
  }
409
410
  private FileEditorTabPane getFileEditorPane() {
411
    if( this.fileEditorPane == null ) {
412
      this.fileEditorPane = createFileEditorPane();
413
    }
414
415
    return this.fileEditorPane;
416
  }
417
418
  private synchronized HTMLPreviewPane getPreviewPane() {
419
    if( this.previewPane == null ) {
420
      this.previewPane = createPreviewPane();
421
    }
422
423
    return this.previewPane;
424
  }
425
426
  private DefinitionPane getDefinitionPane() {
427
    if( this.definitionPane == null ) {
428
      this.definitionPane = createDefinitionPane();
429
    }
430
431
    return this.definitionPane;
432
  }
433
434
  public VariableNameInjector getVariableNameInjector() {
435
    return this.variableNameInjector;
436
  }
437
438
  public void setVariableNameInjector( final VariableNameInjector injector ) {
439
    this.variableNameInjector = injector;
440
  }
441
442
  private Options getOptions() {
443
    return this.options;
444
  }
445
446
  public MenuBar getMenuBar() {
447
    return this.menuBar;
448
  }
449
450
  public void setMenuBar( MenuBar menuBar ) {
451
    this.menuBar = menuBar;
452
  }
453
454
  //---- Member creators ----------------------------------------------------
455
  private DefinitionSource createDefinitionSource( final Path path ) {
456
    return createDefinitionFactory().fileDefinitionSource( path );
457
  }
458
459
  /**
460
   * Create an editor pane to hold file editor tabs.
461
   *
462
   * @return A new instance, never null.
463
   */
464
  private FileEditorTabPane createFileEditorPane() {
465
    return new FileEditorTabPane();
466
  }
467
468
  private HTMLPreviewPane createPreviewPane() {
469
    return new HTMLPreviewPane();
470
  }
471
472
  protected DefinitionPane createDefinitionPane() {
473
    return new DefinitionPane( getTreeView() );
474
  }
475
476
  private DefinitionFactory createDefinitionFactory() {
477
    return new DefinitionFactory();
478
  }
479
480
  private Node createMenuBar() {
481
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
482
483
    // File actions
484
    Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
485
    Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
486
    Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
487
    Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
488
    Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
489
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
490
    Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
491
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
492
    Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
493
494
    // Edit actions
495
    Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
496
      e -> getActiveEditor().undo(),
497
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
498
    Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
499
      e -> getActiveEditor().redo(),
500
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
501
502
    // Insert actions
503
    Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
504
      e -> getActiveEditor().surroundSelection( "**", "**" ),
505
      activeFileEditorIsNull );
506
    Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
507
      e -> getActiveEditor().surroundSelection( "*", "*" ),
508
      activeFileEditorIsNull );
509
    Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
510
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
511
      activeFileEditorIsNull );
512
    Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
513
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
514
      activeFileEditorIsNull );
515
    Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
516
      e -> getActiveEditor().surroundSelection( "`", "`" ),
517
      activeFileEditorIsNull );
518
    Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
519
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
520
      activeFileEditorIsNull );
521
522
    Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
523
      e -> getActiveEditor().insertLink(),
524
      activeFileEditorIsNull );
525
    Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
526
      e -> getActiveEditor().insertImage(),
527
      activeFileEditorIsNull );
528
529
    final Action[] headers = new Action[ 6 ];
530
531
    // Insert header actions (H1 ... H6)
532
    for( int i = 1; i <= 6; i++ ) {
533
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
534
      final String markup = String.format( "\n\n%s ", hashes );
535
      final String text = get( "Main.menu.insert.header_" + i );
536
      final String accelerator = "Shortcut+" + i;
537
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
538
539
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
540
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
541
        activeFileEditorIsNull );
542
    }
543
544
    Action insertUnorderedListAction = new Action( get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
545
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
546
      activeFileEditorIsNull );
547
    Action insertOrderedListAction = new Action( get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
548
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
549
      activeFileEditorIsNull );
550
    Action insertHorizontalRuleAction = new Action( get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
551
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
552
      activeFileEditorIsNull );
553
554
    // Help actions
555
    Action helpAboutAction = new Action( get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
556
557
    //---- MenuBar ----
558
    Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
559
      fileNewAction,
560
      fileOpenAction,
561
      null,
562
      fileCloseAction,
563
      fileCloseAllAction,
564
      null,
565
      fileSaveAction,
566
      fileSaveAllAction,
567
      null,
568
      fileExitAction );
569
570
    Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
571
      editUndoAction,
572
      editRedoAction );
573
574
    Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
575
      insertBoldAction,
576
      insertItalicAction,
577
      insertStrikethroughAction,
578
      insertBlockquoteAction,
579
      insertCodeAction,
580
      insertFencedCodeBlockAction,
581
      null,
582
      insertLinkAction,
583
      insertImageAction,
584
      null,
585
      headers[ 0 ],
586
      headers[ 1 ],
587
      headers[ 2 ],
588
      headers[ 3 ],
589
      headers[ 4 ],
590
      headers[ 5 ],
591
      null,
592
      insertUnorderedListAction,
593
      insertOrderedListAction,
594
      insertHorizontalRuleAction );
595
596
    Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
597
      helpAboutAction );
598
599
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, helpMenu );
600
601
    //---- ToolBar ----
602
    ToolBar toolBar = ActionUtils.createToolBar(
603
      fileNewAction,
604
      fileOpenAction,
605
      fileSaveAction,
606
      null,
607
      editUndoAction,
608
      editRedoAction,
609
      null,
610
      insertBoldAction,
611
      insertItalicAction,
612
      insertBlockquoteAction,
613
      insertCodeAction,
614
      insertFencedCodeBlockAction,
615
      null,
616
      insertLinkAction,
617
      insertImageAction,
618
      null,
619
      headers[ 0 ],
620
      null,
621
      insertUnorderedListAction,
622
      insertOrderedListAction );
623
624
    return new VBox( menuBar, toolBar );
625
  }
626
627
  private void initLayout() {
628
    final SplitPane splitPane = new SplitPane(
629
      getDefinitionPane().getNode(),
630
      getFileEditorPane().getNode(),
631
      getPreviewPane().getNode() );
632
633
    splitPane.setDividerPositions(
634
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
635
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
636
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
637
638
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
639
    final BorderPane borderPane = new BorderPane();
640
    borderPane.setPrefSize( 1024, 800 );
641
    borderPane.setTop( createMenuBar() );
642
    borderPane.setCenter( splitPane );
643
644
    final Scene appScene = new Scene( borderPane );
645
    setScene( appScene );
646
    appScene.getStylesheets().add( STYLESHEET_SCENE );
647
    appScene.windowProperty().addListener(
648
      (observable, oldWindow, newWindow) -> {
649
        newWindow.setOnCloseRequest( e -> {
650
          if( !getFileEditorPane().closeAllEditors() ) {
651
            e.consume();
652
          }
653
        } );
654
655
        // Workaround JavaFX bug: deselect menubar if window loses focus.
656
        newWindow.focusedProperty().addListener(
657
          (obs, oldFocused, newFocused) -> {
658
            if( !newFocused ) {
659
              // Send an ESC key event to the menubar
660
              this.menuBar.fireEvent(
661
                new KeyEvent(
662
                  KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE,
663
                  false, false, false, false ) );
664
            }
665
          }
666
        );
667
      }
668
    );
669
  }
623670
}
624671
M src/main/java/com/scrivenvar/Messages.java
2727
package com.scrivenvar;
2828
29
import static com.scrivenvar.Constants.BUNDLE_NAME;
3029
import java.text.MessageFormat;
3130
import java.util.ResourceBundle;
3231
import java.util.Stack;
32
import static com.scrivenvar.Constants.APP_BUNDLE_NAME;
3333
3434
/**
3535
 * Recursively resolves message properties. Property values can refer to other
3636
 * properties using a <code>${var}</code> syntax.
3737
 *
3838
 * @author Karl Tauber, Dave Jarvis
3939
 */
4040
public class Messages {
4141
42
  private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle.getBundle( BUNDLE_NAME );
42
  private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle.getBundle(APP_BUNDLE_NAME );
4343
4444
  private Messages() {
D src/main/java/com/scrivenvar/TestDefinitionPane.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;
29
30
import com.scrivenvar.definition.DefinitionPane;
31
import static javafx.application.Application.launch;
32
import javafx.scene.control.TreeItem;
33
import javafx.scene.control.TreeView;
34
import javafx.stage.Stage;
35
36
/**
37
 * TestDefinitionPane application for debugging.
38
 */
39
public final class TestDefinitionPane extends TestHarness {
40
  /**
41
   * Application entry point.
42
   *
43
   * @param stage The primary application stage.
44
   *
45
   * @throws Exception Could not read configuration file.
46
   */
47
  @Override
48
  public void start( final Stage stage ) throws Exception {
49
    super.start( stage );
50
51
    TreeView<String> root = createTreeView();
52
    DefinitionPane pane = createDefinitionPane( root );
53
54
    test( pane, "language.ai.", "article" );
55
    test( pane, "language.ai", "ai" );
56
    test( pane, "l", "location" );
57
    test( pane, "la", "language" );
58
    test( pane, "c.p.n", "name" );
59
    test( pane, "c.p.n.", "First" );
60
    test( pane, "...", "c" );
61
    test( pane, "foo", "c" );
62
    test( pane, "foo.bar", "c" );
63
    test( pane, "", "c" );
64
    test( pane, "c", "protagonist" );
65
    test( pane, "c.", "protagonist" );
66
    test( pane, "c.p", "protagonist" );
67
    test( pane, "c.protagonist", "protagonist" );
68
69
    System.exit( 0 );
70
  }
71
72
  private void test( DefinitionPane pane, String path, String value ) {
73
    System.out.println( "---------------------------" );
74
    System.out.println( "Find Path: '" + path + "'" );
75
    final TreeItem<String> node = pane.findNode( path );
76
    System.out.println( "Path Node: " + node );
77
    System.out.println( "Node Val : " + node.getValue() );
78
  }
79
80
  public static void main( String[] args ) {
81
    launch( args );
82
  }
83
}
841
D src/main/java/com/scrivenvar/TestHarness.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;
29
30
import static com.scrivenvar.Messages.get;
31
import com.scrivenvar.definition.DefinitionPane;
32
import com.scrivenvar.yaml.YamlParser;
33
import com.scrivenvar.yaml.YamlTreeAdapter;
34
import java.io.IOException;
35
import java.io.InputStream;
36
import javafx.application.Application;
37
import javafx.scene.Scene;
38
import javafx.scene.control.TreeView;
39
import javafx.scene.layout.BorderPane;
40
import javafx.stage.Stage;
41
import org.fxmisc.flowless.VirtualizedScrollPane;
42
import org.fxmisc.richtext.StyleClassedTextArea;
43
44
/**
45
 * TestDefinitionPane application for debugging and head-banging.
46
 */
47
public abstract class TestHarness extends Application {
48
49
  private static Application app;
50
  private Scene scene;
51
52
  /**
53
   * Application entry point.
54
   *
55
   * @param stage The primary application stage.
56
   *
57
   * @throws Exception Could not read configuration file.
58
   */
59
  @Override
60
  public void start( final Stage stage ) throws Exception {
61
    initApplication();
62
    initScene();
63
    initStage( stage );
64
  }
65
  
66
  protected TreeView<String> createTreeView() throws IOException {
67
    return new YamlTreeAdapter( new YamlParser() ).adapt(
68
      asStream( "/com/scrivenvar/variables.yaml" ),
69
      get( "Pane.defintion.node.root.title" )
70
    );
71
  }
72
  
73
  protected DefinitionPane createDefinitionPane( TreeView<String> root ) {
74
    return new DefinitionPane( root );
75
  }
76
77
  private void initApplication() {
78
    app = this;
79
  }
80
81
  private void initScene() {
82
    final StyleClassedTextArea editor = new StyleClassedTextArea( false );
83
    final VirtualizedScrollPane<StyleClassedTextArea> scrollPane = new VirtualizedScrollPane<>( editor );
84
85
    final BorderPane borderPane = new BorderPane();
86
    borderPane.setPrefSize( 1024, 800 );
87
    borderPane.setCenter( scrollPane );
88
89
    setScene( new Scene( borderPane ) );
90
  }
91
92
  private void initStage( Stage stage ) {
93
    stage.setScene( getScene() );
94
  }
95
96
  private Scene getScene() {
97
    return this.scene;
98
  }
99
100
  private void setScene( Scene scene ) {
101
    this.scene = scene;
102
  }
103
104
  private static Application getApplication() {
105
    return app;
106
  }
107
108
  public static void showDocument( String uri ) {
109
    getApplication().getHostServices().showDocument( uri );
110
  }
111
112
  protected InputStream asStream( String resource ) {
113
    return getClass().getResourceAsStream( resource );
114
  }
115
}
1161
D src/main/java/com/scrivenvar/TestVariableNameProcessor.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;
29
30
import com.scrivenvar.ui.VariableTreeItem;
31
import java.util.Collection;
32
import java.util.HashMap;
33
import java.util.Map;
34
import static java.util.concurrent.ThreadLocalRandom.current;
35
import java.util.concurrent.TimeUnit;
36
import static java.util.concurrent.TimeUnit.DAYS;
37
import static java.util.concurrent.TimeUnit.HOURS;
38
import static java.util.concurrent.TimeUnit.MILLISECONDS;
39
import static java.util.concurrent.TimeUnit.MINUTES;
40
import static java.util.concurrent.TimeUnit.NANOSECONDS;
41
import static java.util.concurrent.TimeUnit.SECONDS;
42
import static javafx.application.Application.launch;
43
import javafx.scene.control.TreeItem;
44
import javafx.scene.control.TreeView;
45
import javafx.stage.Stage;
46
import org.ahocorasick.trie.*;
47
import org.ahocorasick.trie.Trie.TrieBuilder;
48
import static org.apache.commons.lang.RandomStringUtils.randomNumeric;
49
import org.apache.commons.lang.StringUtils;
50
51
/**
52
 * Tests substituting variable definitions with their values in a swath of text.
53
 *
54
 * @author White Magic Software, Ltd.
55
 */
56
public class TestVariableNameProcessor extends TestHarness {
57
58
  private final static int TEXT_SIZE = 1000000;
59
  private final static int MATCHES_DIVISOR = 1000;
60
61
  private final static StringBuilder SOURCE
62
    = new StringBuilder( randomNumeric( TEXT_SIZE ) );
63
64
  private final static boolean DEBUG = false;
65
66
  public TestVariableNameProcessor() {
67
  }
68
69
  @Override
70
  public void start( final Stage stage ) throws Exception {
71
    super.start( stage );
72
73
    final TreeView<String> treeView = createTreeView();
74
    final Map<String, String> definitions = new HashMap<>();
75
76
    populate( treeView.getRoot(), definitions );
77
    injectVariables( definitions );
78
79
    final String text = SOURCE.toString();
80
81
    show( text );
82
83
    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
89
    duration = System.nanoTime() - duration;
90
91
    show( result );
92
    System.out.println( elapsed( duration ) );
93
94
    System.exit( 0 );
95
  }
96
97
  private void show( final String s ) {
98
    if( DEBUG ) {
99
      System.out.printf( "%s\n\n", s );
100
    }
101
  }
102
103
  private String testBorAhoCorasick(
104
    final String text,
105
    final Map<String, String> definitions ) {
106
    // Create a buffer sufficiently large that re-allocations are minimized.
107
    final StringBuilder sb = new StringBuilder( text.length() << 1 );
108
109
    final TrieBuilder builder = Trie.builder();
110
    builder.onlyWholeWords();
111
    builder.removeOverlaps();
112
113
    final String[] keys = keys( definitions );
114
115
    for( final String key : keys ) {
116
      builder.addKeyword( key );
117
    }
118
119
    final Trie trie = builder.build();
120
    final Collection<Emit> emits = trie.parseText( text );
121
122
    int prevIndex = 0;
123
124
    for( final Emit emit : emits ) {
125
      final int matchIndex = emit.getStart();
126
127
      sb.append( text.substring( prevIndex, matchIndex ) );
128
      sb.append( definitions.get( emit.getKeyword() ) );
129
      prevIndex = emit.getEnd() + 1;
130
    }
131
132
    // Add the remainder of the string (contains no more matches).
133
    sb.append( text.substring( prevIndex ) );
134
135
    return sb.toString();
136
  }
137
138
  private String testStringUtils(
139
    final String text, final Map<String, String> definitions ) {
140
    final String[] keys = keys( definitions );
141
    final String[] values = values( definitions );
142
143
    return StringUtils.replaceEach( text, keys, values );
144
  }
145
146
  private String[] keys( final Map<String, String> definitions ) {
147
    final int size = definitions.size();
148
    return definitions.keySet().toArray( new String[ size ] );
149
  }
150
151
  private String[] values( final Map<String, String> definitions ) {
152
    final int size = definitions.size();
153
    return definitions.values().toArray( new String[ size ] );
154
  }
155
156
  /**
157
   * Decomposes a period of time into days, hours, minutes, seconds,
158
   * milliseconds, and nanoseconds.
159
   *
160
   * @param duration Time in nanoseconds.
161
   *
162
   * @return A non-null, comma-separated string (without newline).
163
   */
164
  public String elapsed( long duration ) {
165
    final TimeUnit scale = NANOSECONDS;
166
167
    long days = scale.toDays( duration );
168
    duration -= DAYS.toMillis( days );
169
    long hours = scale.toHours( duration );
170
    duration -= HOURS.toMillis( hours );
171
    long minutes = scale.toMinutes( duration );
172
    duration -= MINUTES.toMillis( minutes );
173
    long seconds = scale.toSeconds( duration );
174
    duration -= SECONDS.toMillis( seconds );
175
    long millis = scale.toMillis( duration );
176
    duration -= MILLISECONDS.toMillis( seconds );
177
    long nanos = scale.toNanos( duration );
178
179
    return String.format(
180
      "%d days, %d hours, %d minutes, %d seconds, %d millis, %d nanos",
181
      days, hours, minutes, seconds, millis, nanos
182
    );
183
  }
184
185
  private void injectVariables( final Map<String, String> definitions ) {
186
    for( int i = (SOURCE.length() / MATCHES_DIVISOR) + 1; i > 0; i-- ) {
187
      final int r = current().nextInt( 1, SOURCE.length() );
188
      SOURCE.insert( r, randomKey( definitions ) );
189
    }
190
  }
191
192
  private String randomKey( final Map<String, String> map ) {
193
    final Object[] keys = map.keySet().toArray();
194
    final int r = current().nextInt( keys.length );
195
    return keys[ r ].toString();
196
  }
197
198
  private void populate( final TreeItem<String> parent, final Map<String, String> map ) {
199
    for( final TreeItem<String> child : parent.getChildren() ) {
200
      if( child.isLeaf() ) {
201
        final String key = asDefinition( ((VariableTreeItem<String>)child).toPath() );
202
        final String value = child.getValue();
203
204
        map.put( key, value );
205
      } else {
206
        populate( child, map );
207
      }
208
    }
209
  }
210
211
  private String asDefinition( final String key ) {
212
    return "$" + key + "$";
213
  }
214
215
  public static void main( String[] args ) {
216
    launch( args );
217
  }
218
}
2191
A src/main/java/com/scrivenvar/definition/AbstractDefinitionSource.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.definition;
29
30
/**
31
 * Implements common behaviour for definition sources.
32
 * 
33
 * @author White Magic Software, Ltd.
34
 */
35
public abstract class AbstractDefinitionSource implements DefinitionSource {
36
}
137
A src/main/java/com/scrivenvar/definition/DefinitionFactory.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.definition;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.definition.yaml.YamlFileDefinitionSource;
32
import com.scrivenvar.predicates.files.FileTypePredicate;
33
import com.scrivenvar.service.Settings;
34
import java.nio.file.Path;
35
import java.util.Iterator;
36
import java.util.List;
37
38
/**
39
 * Responsible for creating objects that can read and write definition data
40
 * sources. The data source could be YAML, TOML, JSON, flat files, or from a
41
 * database.
42
 *
43
 * @author White Magic Software, Ltd.
44
 */
45
public class DefinitionFactory {
46
47
  /**
48
   * Refers to filename extension settings in the configuration file. Do not
49
   * terminate this key prefix with a period.
50
   */
51
  private static final String EXTENSIONS_PREFIX = "file.ext.definition";
52
53
  private final Settings settings = Services.load( Settings.class );
54
55
  /**
56
   * Default (empty) constructor.
57
   */
58
  public DefinitionFactory() {
59
  }
60
61
  /**
62
   * Creates a definition source that can read and write files that match the
63
   * given file type (from the path).
64
   *
65
   * @param path Reference to a variable definition file.
66
   *
67
   * @return
68
   */
69
  public DefinitionSource fileDefinitionSource( final Path path ) {
70
    final Settings properties = getSettings();
71
    final Iterator<String> keys = properties.getKeys( EXTENSIONS_PREFIX );
72
73
    DefinitionSource definitions = null;
74
75
    while( keys.hasNext() ) {
76
      final String key = keys.next();
77
      final List<String> patterns = properties.getStringSettingList( key );
78
      final FileTypePredicate predicate = new FileTypePredicate( patterns );
79
80
      if( predicate.test( path.toFile() ) ) {
81
        final String filetype = key.replace( EXTENSIONS_PREFIX + ".", "" );
82
83
        definitions = createFileDefinitionSource( filetype, path );
84
      }
85
    }
86
87
    return definitions;
88
  }
89
90
  /**
91
   * Creates a definition source based on the file type.
92
   *
93
   * @param filetype Property key name suffix from settings.properties file.
94
   * @param path Path to the file that corresponds to the extension.
95
   *
96
   * @return A DefinitionSource capable of parsing the data stored at the path.
97
   */
98
  private DefinitionSource createFileDefinitionSource(
99
    final String filetype, final Path path ) {
100
    final DefinitionSource result;
101
102
    switch( filetype ) {
103
      case "yaml":
104
        result = new YamlFileDefinitionSource( path );
105
        break;
106
107
      default:
108
        result = new EmptyDefinitionSource();
109
        break;
110
    }
111
112
    return result;
113
  }
114
115
  private Settings getSettings() {
116
    return this.settings;
117
  }
118
}
1119
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
2828
package com.scrivenvar.definition;
2929
30
import static com.scrivenvar.Constants.SEPARATOR;
31
import static com.scrivenvar.definition.Lists.getFirst;
30
import com.scrivenvar.AbstractPane;
31
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
3232
import com.scrivenvar.predicates.strings.ContainsPredicate;
3333
import com.scrivenvar.predicates.strings.StartsPredicate;
3434
import com.scrivenvar.predicates.strings.StringPredicate;
35
import com.scrivenvar.ui.AbstractPane;
36
import com.scrivenvar.ui.VariableTreeItem;
35
import static com.scrivenvar.util.Lists.getFirst;
3736
import java.util.List;
3837
import javafx.collections.ObservableList;
...
5049
public class DefinitionPane extends AbstractPane {
5150
51
  /**
52
   * Trimmed off the end of a word to match a variable name.
53
   */
5254
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
5355
...
6365
    setTreeView( root );
6466
    initTreeView();
67
  }
68
69
  public void clear() {
70
    getTreeView().setRoot( null );
6571
  }
6672
...
127133
   * </ol>
128134
   *
129
   * @param path The word typed by the user, which contains dot-separated node
135
   * @param word The word typed by the user, which contains dot-separated node
130136
   * names that represent a path within the YAML tree plus a partial variable
131137
   * name match (for a node).
132138
   *
133139
   * @return The node value that starts with the suffix portion of the given
134140
   * path, never null.
135141
   */
136
  public TreeItem<String> findNode( String path ) {
142
  public TreeItem<String> findNode( final String word ) {
143
    String path = word;
144
137145
    TreeItem<String> cItem = getTreeRoot();
138146
    TreeItem<String> pItem = cItem;
A src/main/java/com/scrivenvar/definition/DefinitionSource.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.definition;
29
30
import java.io.IOException;
31
import java.util.Map;
32
import javafx.scene.control.TreeView;
33
34
/**
35
 * Represents behaviours for reading and writing variable definitions.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public interface DefinitionSource {
40
41
  /**
42
   * Creates a TreeView from this definition source. The definition source is
43
   * responsible for observing the TreeView instance for changes and persisting
44
   * them, if needed.
45
   *
46
   * @return A hierarchical tree suitable for displaying in the definition pane.
47
   *
48
   * @throws IOException Could not obtain the definition source data.
49
   */
50
  public TreeView<String> asTreeView() throws IOException;
51
  
52
  /**
53
   * Returns all the strings with their values resolved in a flat hierarchy.
54
   * This copies all the keys and resolved values into a new map.
55
   *
56
   * @return The new map created with all values having been resolved,
57
   * recursively.
58
   */
59
  public Map<String, String> getResolvedMap();
60
}
161
A src/main/java/com/scrivenvar/definition/EmptyDefinitionSource.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.definition;
29
30
import java.io.IOException;
31
import java.util.HashMap;
32
import java.util.Map;
33
import javafx.scene.control.TreeView;
34
35
/**
36
 * Creates a definition source that has no information to load or save.
37
 *
38
 * @author White Magic Software, Ltd.
39
 */
40
public class EmptyDefinitionSource extends AbstractDefinitionSource {
41
42
  public EmptyDefinitionSource() {
43
  }
44
45
  @Override
46
  public TreeView<String> asTreeView() throws IOException {
47
    return new TreeView<>();
48
  }
49
50
  @Override
51
  public Map<String, String> getResolvedMap() {
52
    return new HashMap<>();
53
  }
54
55
}
156
D src/main/java/com/scrivenvar/definition/Lists.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.definition;
29
30
import java.util.List;
31
32
/**
33
 * Convenience class that provides a clearer API for obtaining list elements.
34
 *
35
 * @author White Magic Software, Ltd.
36
 */
37
public final class Lists {
38
39
  private Lists() {
40
  }
41
42
  /**
43
   * Returns the first item in the given list, or null if not found.
44
   *
45
   * @param <T> The generic list type.
46
   * @param list The list that may have a first item.
47
   *
48
   * @return null if the list is null or there is no first item.
49
   */
50
  public static <T> T getFirst( final List<T> list ) {
51
    return getFirst( list, null );
52
  }
53
54
  /**
55
   * Returns the last item in the given list, or null if not found.
56
   *
57
   * @param <T> The generic list type.
58
   * @param list The list that may have a last item.
59
   *
60
   * @return null if the list is null or there is no last item.
61
   */
62
  public static <T> T getLast( final List<T> list ) {
63
    return getLast( list, null );
64
  }
65
66
  /**
67
   * Returns the first item in the given list, or t if not found.
68
   *
69
   * @param <T> The generic list type.
70
   * @param list The list that may have a first item.
71
   * @param t The default return value.
72
   *
73
   * @return null if the list is null or there is no first item.
74
   */
75
  public static <T> T getFirst( final List<T> list, final T t ) {
76
    return isEmpty( list ) ? t : list.get( 0 );
77
  }
78
79
  /**
80
   * Returns the last item in the given list, or t if not found.
81
   *
82
   * @param <T> The generic list type.
83
   * @param list The list that may have a last item.
84
   * @param t The default return value.
85
   *
86
   * @return null if the list is null or there is no last item.
87
   */
88
  public static <T> T getLast( final List<T> list, final T t ) {
89
    return isEmpty( list ) ? t : list.get( list.size() - 1 );
90
  }
91
92
  /**
93
   * Returns true if the given list is null or empty.
94
   *
95
   * @param <T> The generic list type.
96
   * @param list The list that has a last item.
97
   *
98
   * @return true The list is empty.
99
   */
100
  public static <T> boolean isEmpty( final List<T> list ) {
101
    return list == null || list.isEmpty();
102
  }
103
}
1041
A src/main/java/com/scrivenvar/definition/VariableTreeItem.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.definition;
29
30
import com.scrivenvar.decorators.VariableDecorator;
31
import com.scrivenvar.decorators.YamlVariableDecorator;
32
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
33
import static com.scrivenvar.editors.VariableNameInjector.DEFAULT_MAX_VAR_LENGTH;
34
import java.util.HashMap;
35
import java.util.Map;
36
import java.util.Stack;
37
import javafx.scene.control.TreeItem;
38
39
/**
40
 * Provides behaviour afforded to variable names and their corresponding value.
41
 *
42
 * @author White Magic Software, Ltd.
43
 * @param <T> The type of TreeItem (usually String).
44
 */
45
public class VariableTreeItem<T> extends TreeItem<T> {
46
47
  private final static int DEFAULT_MAP_SIZE = 1000;
48
  
49
  private final static VariableDecorator VARIABLE_DECORATOR =
50
    new YamlVariableDecorator();
51
52
  /**
53
   * Flattened tree.
54
   */
55
  private Map<String, String> map;
56
57
  /**
58
   * Constructs a new item with a default value.
59
   *
60
   * @param value Passed up to superclass.
61
   */
62
  public VariableTreeItem( final T value ) {
63
    super( value );
64
  }
65
66
  /**
67
   * Finds a leaf starting at the current node with text that matches the given
68
   * value.
69
   *
70
   * @param text The text to match against each leaf in the tree.
71
   *
72
   * @return The leaf that has a value starting with the given text.
73
   */
74
  public VariableTreeItem<T> findLeaf( final String text ) {
75
    final Stack<VariableTreeItem<T>> stack = new Stack<>();
76
    final VariableTreeItem<T> root = this;
77
78
    stack.push( root );
79
80
    boolean found = false;
81
    VariableTreeItem<T> node = null;
82
83
    while( !found && !stack.isEmpty() ) {
84
      node = stack.pop();
85
86
      if( node.valueStartsWith( text ) ) {
87
        found = true;
88
      } else {
89
        for( final TreeItem<T> child : node.getChildren() ) {
90
          stack.push( (VariableTreeItem<T>)child );
91
        }
92
93
        // No match found, yet.
94
        node = null;
95
      }
96
    }
97
98
    return (VariableTreeItem<T>)node;
99
  }
100
101
  /**
102
   * Returns true if this node is a leaf and its value starts with the given
103
   * text.
104
   *
105
   * @param s The text to compare against the node value.
106
   *
107
   * @return true Node is a leaf and its value starts with the given value.
108
   */
109
  private boolean valueStartsWith( final String s ) {
110
    return isLeaf() && getValue().toString().startsWith( s );
111
  }
112
113
  /**
114
   * Returns the path for this node, with nodes made distinct using the
115
   * separator character. This uses two loops: one for pushing nodes onto a
116
   * stack and one for popping them off to create the path in desired order.
117
   *
118
   * @return A non-null string, possibly empty.
119
   */
120
  public String toPath() {
121
    final Stack<TreeItem<T>> stack = new Stack<>();
122
    TreeItem<T> node = this;
123
124
    while( node.getParent() != null ) {
125
      stack.push( node );
126
      node = node.getParent();
127
    }
128
129
    final StringBuilder sb = new StringBuilder( DEFAULT_MAX_VAR_LENGTH );
130
131
    while( !stack.isEmpty() ) {
132
      node = stack.pop();
133
134
      if( !node.isLeaf() ) {
135
        sb.append( node.getValue() );
136
137
        // This will add a superfluous separator, but instead of peeking at
138
        // the stack all the time, the last separator will be removed outside
139
        // the loop (one operation executed once).
140
        sb.append( SEPARATOR );
141
      }
142
    }
143
144
    // Remove the trailing SEPARATOR.
145
    if( sb.length() > 0 ) {
146
      sb.setLength( sb.length() - 1 );
147
    }
148
149
    return sb.toString();
150
  }
151
152
  /**
153
   * Returns the hierarchy, flattened to key-value pairs.
154
   *
155
   * @return A map of this tree's key-value pairs.
156
   */
157
  public Map<String, String> getMap() {
158
    if( this.map == null ) {
159
      this.map = new HashMap<>( DEFAULT_MAP_SIZE );
160
      populate( this, this.map );
161
    }
162
163
    return this.map;
164
  }
165
166
  private void populate( final TreeItem<T> parent, final Map<String, String> map ) {
167
    for( final TreeItem<T> child : parent.getChildren() ) {
168
      if( child.isLeaf() ) {
169
        @SuppressWarnings( "unchecked" )
170
        final String key = toVariable( ((VariableTreeItem<String>)child).toPath() );
171
        final String value = child.getValue().toString();
172
173
        map.put( key, value );
174
      } else {
175
        populate( child, map );
176
      }
177
    }
178
  }
179
180
  /**
181
   * Converts the name of the key to a simple variable by enclosing it with
182
   * dollar symbols.
183
   *
184
   * @param key The key name to change to a variable.
185
   *
186
   * @return $key$
187
   */
188
  public String toVariable( final String key ) {
189
    return VARIABLE_DECORATOR.decorate( key );
190
  }
191
}
1192
A src/main/java/com/scrivenvar/definition/yaml/FileDefinitionSource.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.definition.yaml;
29
30
import com.scrivenvar.definition.AbstractDefinitionSource;
31
import java.nio.file.Path;
32
33
/**
34
 * Implements common behaviour for file definition sources.
35
 *
36
 * @author White Magic Software, Ltd.
37
 */
38
public abstract class FileDefinitionSource extends AbstractDefinitionSource {
39
40
  private Path path;
41
42
  /**
43
   * Constructs a new file definition source that can read and write data in the
44
   * hierarchical format contained within the file location specified by the
45
   * path.
46
   *
47
   * @param path Must not be null.
48
   */
49
  public FileDefinitionSource( final Path path ) {
50
    setPath( path );
51
  }
52
53
  private void setPath( final Path path ) {
54
    this.path = path;
55
  }
56
57
  protected Path getPath() {
58
    return this.path;
59
  }
60
61
  /**
62
   * Returns the path represented by this object.
63
   *
64
   * @return The
65
   */
66
  @Override
67
  public String toString() {
68
    return getPath().toString();
69
  }
70
}
171
A src/main/java/com/scrivenvar/definition/yaml/YamlFileDefinitionSource.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.definition.yaml;
29
30
import static com.scrivenvar.Messages.get;
31
import java.io.IOException;
32
import java.io.InputStream;
33
import java.nio.file.Files;
34
import java.nio.file.Path;
35
import java.util.Map;
36
import javafx.scene.control.TreeView;
37
38
/**
39
 * Represents a definition data source for YAML files.
40
 *
41
 * @author White Magic Software, Ltd.
42
 */
43
public class YamlFileDefinitionSource extends FileDefinitionSource {
44
45
  private YamlTreeAdapter yamlTreeAdapter;
46
  private YamlParser yamlParser;
47
48
  /**
49
   * Constructs a new YAML definition source, populated from the given file.
50
   *
51
   * @param path Path to the YAML definition file.
52
   */
53
  public YamlFileDefinitionSource( final Path path ) {
54
    super( path );
55
  }
56
57
  /**
58
   * TODO: Associate variable file with path to current file.
59
   *
60
   * @return The TreeView for this definition source.
61
   *
62
   * @throws IOException
63
   */
64
  @Override
65
  public TreeView<String> asTreeView() throws IOException {
66
67
    try( final InputStream in = Files.newInputStream( getPath() ) ) {
68
      return getYamlTreeAdapter().adapt(
69
        in,
70
        get( "Pane.defintion.node.root.title" )
71
      );
72
    }
73
  }
74
75
  @Override
76
  public Map<String, String> getResolvedMap() {
77
    return getYamlParser().createResolvedMap();
78
  }
79
80
  private YamlTreeAdapter getYamlTreeAdapter() {
81
    if( this.yamlTreeAdapter == null ) {
82
      setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
83
    }
84
85
    return this.yamlTreeAdapter;
86
  }
87
88
  private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
89
    this.yamlTreeAdapter = yamlTreeAdapter;
90
  }
91
92
  private YamlParser getYamlParser() {
93
    if( this.yamlParser == null ) {
94
      setYamlParser( new YamlParser() );
95
    }
96
97
    return this.yamlParser;
98
  }
99
100
  private void setYamlParser( final YamlParser yamlParser ) {
101
    this.yamlParser = yamlParser;
102
  }
103
104
  private InputStream asStream( final String resource ) {
105
    return getClass().getResourceAsStream( resource );
106
  }
107
}
1108
A src/main/java/com/scrivenvar/definition/yaml/YamlParser.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.definition.yaml;
29
30
import com.fasterxml.jackson.core.JsonGenerationException;
31
import com.fasterxml.jackson.core.ObjectCodec;
32
import com.fasterxml.jackson.core.io.IOContext;
33
import com.fasterxml.jackson.databind.JsonNode;
34
import com.fasterxml.jackson.databind.ObjectMapper;
35
import com.fasterxml.jackson.databind.node.ObjectNode;
36
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
37
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
38
import com.scrivenvar.decorators.VariableDecorator;
39
import com.scrivenvar.decorators.YamlVariableDecorator;
40
import java.io.IOException;
41
import java.io.InputStream;
42
import java.io.Writer;
43
import java.security.InvalidParameterException;
44
import java.text.MessageFormat;
45
import java.util.HashMap;
46
import java.util.Map;
47
import java.util.Map.Entry;
48
import java.util.regex.Matcher;
49
import java.util.regex.Pattern;
50
import org.yaml.snakeyaml.DumperOptions;
51
52
/**
53
 * <p>
54
 * This program loads a YAML document into memory, scans for variable
55
 * declarations, then substitutes any self-referential values back into the
56
 * document. Its output is the given YAML document without any variables.
57
 * Variables in the YAML document are denoted using a bracketed dollar symbol
58
 * syntax. For example: $field.name$. Some nomenclature to keep from going
59
 * squirrely, consider:
60
 * </p>
61
 *
62
 * <pre>
63
 *   root:
64
 *     node:
65
 *       name: $field.name$
66
 *   field:
67
 *     name: Alan Turing
68
 * </pre>
69
 *
70
 * The various components of the given YAML are called:
71
 *
72
 * <ul>
73
 * <li><code>$field.name$</code> - delimited reference</li>
74
 * <li><code>field.name</code> - reference</li>
75
 * <li><code>name</code> - YAML field</li>
76
 * <li><code>Alan Turing</code> - (dereferenced) field value</li>
77
 * </ul>
78
 *
79
 * @author White Magic Software, Ltd.
80
 */
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
    rootNode.fields().forEachRemaining(
171
      (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map )
172
    );
173
  }
174
175
  /**
176
   * Recursively adapt each rootNode to a corresponding rootItem.
177
   *
178
   * @param rootNode The node to adapt.
179
   */
180
  private void resolve(
181
    final Entry<String, JsonNode> rootNode,
182
    final String path,
183
    final Map<String, String> map ) {
184
185
    final JsonNode leafNode = rootNode.getValue();
186
    final String key = rootNode.getKey();
187
188
    if( leafNode.isValueNode() ) {
189
      final String value = rootNode.getValue().asText();
190
191
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
192
    }
193
194
    if( leafNode.isObject() ) {
195
      resolve( leafNode, path + key + SEPARATOR, map );
196
    }
197
  }
198
199
  /**
200
   * Reads the first document from the given stream of YAML data and returns a
201
   * corresponding object that represents the YAML hierarchy. The calling class
202
   * is responsible for closing the stream. Calling classes should use
203
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
204
   *
205
   * @param in The input stream containing YAML content.
206
   *
207
   * @return An object hierarchy to represent the content.
208
   *
209
   * @throws IOException Could not read the stream.
210
   */
211
  public JsonNode process( final InputStream in ) throws IOException {
212
213
    final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in );
214
    setDocumentRoot( root );
215
    process( root );
216
    return getDocumentRoot();
217
  }
218
219
  /**
220
   * Iterate over a given root node (at any level of the tree) and process each
221
   * leaf node.
222
   *
223
   * @param root A node to process.
224
   */
225
  private void process( final JsonNode root ) {
226
    root.fields().forEachRemaining( this::process );
227
  }
228
229
  /**
230
   * Process the given field, which is a named node. This is where the
231
   * application does the up-front work of mapping references to their fully
232
   * recursively dereferenced values.
233
   *
234
   * @param field The named node.
235
   */
236
  private void process( final Entry<String, JsonNode> field ) {
237
    final JsonNode node = field.getValue();
238
239
    if( node.isObject() ) {
240
      process( node );
241
    } else {
242
      final JsonNode fieldValue = field.getValue();
243
244
      // Only basic data types can be parsed into variable values. For
245
      // node structures, YAML has a built-in mechanism.
246
      if( fieldValue.isValueNode() ) {
247
        try {
248
          resolve( fieldValue.asText() );
249
        } catch( StackOverflowError e ) {
250
          throw new IllegalArgumentException(
251
            "Unresolvable: " + node.textValue() + " = " + fieldValue );
252
        }
253
      }
254
    }
255
  }
256
257
  /**
258
   * Inserts the delimited references and field values into the cache. This will
259
   * overwrite existing references.
260
   *
261
   * @param fieldValue YAML field containing zero or more delimited references.
262
   * If it contains a delimited reference, the parameter is modified with the
263
   * dereferenced value before it is returned.
264
   *
265
   * @return fieldValue without delimited references.
266
   */
267
  private String resolve( String fieldValue ) {
268
    final Matcher matcher = patternMatch( fieldValue );
269
270
    while( matcher.find() ) {
271
      final String delimited = matcher.group( GROUP_DELIMITED );
272
      final String reference = matcher.group( GROUP_REFERENCE );
273
      final String dereference = resolve( lookup( reference ) );
274
275
      fieldValue = fieldValue.replace( delimited, dereference );
276
277
      // This will perform some superfluous calls by overwriting existing
278
      // items in the delimited reference map.
279
      put( delimited, dereference );
280
    }
281
282
    return fieldValue;
283
  }
284
285
  /**
286
   * Inserts a key/value pair into the references map. The map retains
287
   * references and dereferenced values found in the YAML. If the reference
288
   * already exists, this will overwrite with a new value.
289
   *
290
   * @param delimited The variable name.
291
   * @param dereferenced The resolved value.
292
   */
293
  private void put( String delimited, String dereferenced ) {
294
    if( dereferenced.isEmpty() ) {
295
      missing( delimited );
296
    } else {
297
      getReferences().put( delimited, dereferenced );
298
    }
299
  }
300
301
  /**
302
   * Writes the modified YAML document to standard output.
303
   */
304
  private void writeDocument() throws IOException {
305
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
306
  }
307
308
  /**
309
   * Called when a delimited reference is dereferenced to an empty string. This
310
   * should produce a warning for the user.
311
   *
312
   * @param delimited Delimited reference with no derived value.
313
   */
314
  private void missing( final String delimited ) {
315
    throw new InvalidParameterException(
316
      MessageFormat.format( "Missing value for '{0}'.", delimited ) );
317
  }
318
319
  /**
320
   * Returns a REGEX_PATTERN matcher for the given text.
321
   *
322
   * @param text The text that contains zero or more instances of a
323
   * REGEX_PATTERN that can be found using the regular expression.
324
   */
325
  private Matcher patternMatch( String text ) {
326
    return getPattern().matcher( text );
327
  }
328
329
  /**
330
   * Finds the YAML value for a reference.
331
   *
332
   * @param reference References a value in the YAML document.
333
   *
334
   * @return The dereferenced value.
335
   */
336
  private String lookup( final String reference ) {
337
    return getDocumentRoot().at( asPath( reference ) ).asText();
338
  }
339
340
  /**
341
   * Converts a reference (not delimited) to a path that can be used to find a
342
   * value that should exist inside the YAML document.
343
   *
344
   * @param reference The reference to convert to a YAML document path.
345
   *
346
   * @return The reference with a leading slash and its separator characters
347
   * converted to slashes.
348
   */
349
  private String asPath( final String reference ) {
350
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML );
351
  }
352
353
  /**
354
   * Sets the parent node for the entire YAML document tree.
355
   *
356
   * @param documentRoot The parent node.
357
   */
358
  private void setDocumentRoot( ObjectNode documentRoot ) {
359
    this.documentRoot = documentRoot;
360
  }
361
362
  /**
363
   * Returns the parent node for the entire YAML document tree.
364
   *
365
   * @return The parent node.
366
   */
367
  private ObjectNode getDocumentRoot() {
368
    return this.documentRoot;
369
  }
370
371
  /**
372
   * Returns the compiled regular expression REGEX_PATTERN used to match
373
   * delimited references.
374
   *
375
   * @return A compiled regex for use with the Matcher.
376
   */
377
  private Pattern getPattern() {
378
    return REGEX_PATTERN;
379
  }
380
381
  /**
382
   * Returns the list of references mapped to dereferenced values.
383
   *
384
   * @return
385
   */
386
  private Map<String, String> getReferences() {
387
    if( this.references == null ) {
388
      this.references = createReferences();
389
    }
390
391
    return this.references;
392
  }
393
394
  /**
395
   * Subclasses can override this method to insert their own map.
396
   *
397
   * @return An empty HashMap, never null.
398
   */
399
  protected Map<String, String> createReferences() {
400
    return new HashMap<>();
401
  }
402
403
  private class ResolverYAMLFactory extends YAMLFactory {
404
405
    @Override
406
    protected YAMLGenerator _createGenerator(
407
      final Writer out, final IOContext ctxt ) throws IOException {
408
409
      return new ResolverYAMLGenerator(
410
        ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec,
411
        out, _version );
412
    }
413
  }
414
415
  private class ResolverYAMLGenerator extends YAMLGenerator {
416
417
    public ResolverYAMLGenerator(
418
      final IOContext ctxt,
419
      final int jsonFeatures,
420
      final int yamlFeatures,
421
      final ObjectCodec codec,
422
      final Writer out,
423
      final DumperOptions.Version version ) throws IOException {
424
425
      super( ctxt, jsonFeatures, yamlFeatures, codec, out, version );
426
    }
427
428
    @Override
429
    public void writeString( final String text )
430
      throws IOException, JsonGenerationException {
431
      super.writeString( substitute( text ) );
432
    }
433
  }
434
435
  private YAMLFactory getYAMLFactory() {
436
    return new ResolverYAMLFactory();
437
  }
438
439
  private ObjectMapper getObjectMapper() {
440
    return new ObjectMapper( getYAMLFactory() );
441
  }
442
443
  /**
444
   * Returns the character used to separate YAML paths within delimited
445
   * references. This will return only the first character of the command line
446
   * parameter, if the default is overridden.
447
   *
448
   * @return A period by default.
449
   */
450
  private char getDelimitedSeparator() {
451
    return SEPARATOR.charAt( 0 );
452
  }
453
}
1454
A src/main/java/com/scrivenvar/definition/yaml/YamlTreeAdapter.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.definition.yaml;
29
30
import com.fasterxml.jackson.databind.JsonNode;
31
import com.scrivenvar.definition.VariableTreeItem;
32
import java.io.IOException;
33
import java.io.InputStream;
34
import java.util.Map.Entry;
35
import javafx.scene.control.TreeItem;
36
import javafx.scene.control.TreeView;
37
38
/**
39
 * Transforms a JsonNode hierarchy into a tree that can be displayed in a user
40
 * interface.
41
 *
42
 * @author White Magic Software, Ltd.
43
 */
44
public class YamlTreeAdapter {
45
46
  private YamlParser yamlParser;
47
48
  public YamlTreeAdapter( final YamlParser parser ) {
49
    setYamlParser( parser );
50
  }
51
52
  /**
53
   * Converts a YAML document to a TreeView based on the document keys. Only the
54
   * first document in the stream is adapted. This does not close the stream.
55
   *
56
   * @param in Contains a YAML document.
57
   * @param name Root TreeItem node name.
58
   *
59
   * @return A TreeView populated with all the keys in the YAML document.
60
   *
61
   * @throws IOException Could not read from the stream.
62
   */
63
  public TreeView<String> adapt( final InputStream in, final String name )
64
    throws IOException {
65
66
    final JsonNode rootNode = getYamlParser().process( in );
67
    final TreeItem<String> rootItem = createTreeItem( name );
68
69
    rootItem.setExpanded( true );
70
    adapt( rootNode, rootItem );
71
    return new TreeView<>( rootItem );
72
  }
73
74
  /**
75
   * Iterate over a given root node (at any level of the tree) and adapt each
76
   * leaf node.
77
   *
78
   * @param rootNode A JSON node (YAML node) to adapt.
79
   * @param rootItem The tree item to use as the root when processing the node.
80
   */
81
  private void adapt(
82
    final JsonNode rootNode, final TreeItem<String> rootItem ) {
83
84
    rootNode.fields().forEachRemaining(
85
      (Entry<String, JsonNode> leaf) -> adapt( leaf, rootItem )
86
    );
87
  }
88
89
  /**
90
   * Recursively adapt each rootNode to a corresponding rootItem.
91
   *
92
   * @param rootNode The node to adapt.
93
   * @param rootItem The item to adapt using the node's key.
94
   */
95
  private void adapt(
96
    final Entry<String, JsonNode> rootNode, final TreeItem<String> rootItem ) {
97
98
    final JsonNode leafNode = rootNode.getValue();
99
    final String key = rootNode.getKey();
100
    final TreeItem<String> leaf = createTreeItem( key );
101
102
    if( leafNode.isValueNode() ) {
103
      leaf.getChildren().add( createTreeItem( rootNode.getValue().asText() ) );
104
    }
105
106
    rootItem.getChildren().add( leaf );
107
108
    if( leafNode.isObject() ) {
109
      adapt( leafNode, leaf );
110
    }
111
  }
112
113
  /**
114
   * Creates a new tree item that can be added to the tree view.
115
   *
116
   * @param value The node's value.
117
   *
118
   * @return A new tree item node, never null.
119
   */
120
  private TreeItem<String> createTreeItem( final String value ) {
121
    return new VariableTreeItem<>( value );
122
  }
123
124
  private YamlParser getYamlParser() {
125
    return this.yamlParser;
126
  }
127
128
  private void setYamlParser( final YamlParser yamlParser ) {
129
    this.yamlParser = yamlParser;
130
  }
131
}
1132
M src/main/java/com/scrivenvar/dialogs/LinkDialog.java
3030
import com.scrivenvar.Messages;
3131
import com.scrivenvar.controls.EscapeTextField;
32
import com.scrivenvar.editor.HyperlinkModel;
32
import com.scrivenvar.editors.markdown.HyperlinkModel;
3333
import com.scrivenvar.service.events.impl.ButtonOrderPane;
3434
import java.nio.file.Path;
D src/main/java/com/scrivenvar/editor/EditorPane.java
1
/*
2
 * Copyright 2016 Karl Tauber and 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.editor;
29
30
import com.scrivenvar.ui.AbstractPane;
31
import java.nio.file.Path;
32
import java.util.function.Consumer;
33
import javafx.application.Platform;
34
import javafx.beans.property.ObjectProperty;
35
import javafx.beans.property.SimpleObjectProperty;
36
import javafx.beans.value.ChangeListener;
37
import javafx.event.Event;
38
import javafx.scene.control.ScrollPane;
39
import javafx.scene.input.InputEvent;
40
import org.fxmisc.flowless.VirtualizedScrollPane;
41
import org.fxmisc.richtext.StyleClassedTextArea;
42
import org.fxmisc.undo.UndoManager;
43
import org.fxmisc.wellbehaved.event.EventPattern;
44
import org.fxmisc.wellbehaved.event.InputMap;
45
import static org.fxmisc.wellbehaved.event.InputMap.consume;
46
import org.fxmisc.wellbehaved.event.Nodes;
47
48
/**
49
 * Represents common editing features for various types of text editors.
50
 *
51
 * @author White Magic Software, Ltd.
52
 */
53
public class EditorPane extends AbstractPane {
54
55
  private StyleClassedTextArea editor;
56
  private VirtualizedScrollPane<StyleClassedTextArea> scrollPane;
57
  private final ObjectProperty<Path> path = new SimpleObjectProperty<>();
58
59
  /**
60
   * Set when entering variable edit mode; retrieved upon exiting.
61
   */
62
  private InputMap<InputEvent> nodeMap;
63
64
  @Override
65
  public void requestFocus() {
66
    Platform.runLater( () -> getEditor().requestFocus() );
67
  }
68
69
  public void undo() {
70
    getUndoManager().undo();
71
  }
72
73
  public void redo() {
74
    getUndoManager().redo();
75
  }
76
77
  public UndoManager getUndoManager() {
78
    return getEditor().getUndoManager();
79
  }
80
81
  public String getText() {
82
    return getEditor().getText();
83
  }
84
85
  public void setText( final String text ) {
86
    getEditor().deselect();
87
    getEditor().replaceText( text );
88
    getUndoManager().mark();
89
  }
90
91
  /**
92
   * Call to hook into changes to the text area.
93
   *
94
   * @param listener Receives editor text change events.
95
   */
96
  public void addTextChangeListener( final ChangeListener<? super String> listener ) {
97
    getEditor().textProperty().addListener( listener );
98
  }
99
100
  /**
101
   * Call to listen for when the caret moves to another paragraph.
102
   * 
103
   * @param listener Receives paragraph change events.
104
   */
105
  public void addCaretParagraphListener(
106
    final ChangeListener<? super Integer> listener ) {
107
    getEditor().currentParagraphProperty().addListener( listener );
108
  }
109
  
110
  /**
111
   * This method adds listeners to editor events.
112
   *
113
   * @param <T> The event type.
114
   * @param <U> The consumer type for the given event type.
115
   * @param event The event of interest.
116
   * @param consumer The method to call when the event happens.
117
   */
118
  public <T extends Event, U extends T> void addEventListener(
119
    final EventPattern<? super T, ? extends U> event,
120
    final Consumer<? super U> consumer ) {
121
    Nodes.addInputMap( getEditor(), consume( event, consumer ) );
122
  }
123
124
  /**
125
   * This method adds listeners to editor events that can be removed without
126
   * affecting the original listeners (i.e., the original lister is restored on
127
   * a call to removeEventListener).
128
   *
129
   * @param map The map of methods to events.
130
   */
131
  @SuppressWarnings( "unchecked" )
132
  public void addEventListener( final InputMap<InputEvent> map ) {
133
    this.nodeMap = (InputMap<InputEvent>)getInputMap();
134
    Nodes.addInputMap( getEditor(), map );
135
  }
136
137
  /**
138
   * This method removes listeners to editor events and restores the default
139
   * handler.
140
   *
141
   * @param map The map of methods to events.
142
   */
143
  public void removeEventListener( final InputMap<InputEvent> map ) {
144
    Nodes.removeInputMap( getEditor(), map );
145
    Nodes.addInputMap( getEditor(), this.nodeMap );
146
  }
147
148
  /**
149
   * Returns the value for "org.fxmisc.wellbehaved.event.inputmap".
150
   *
151
   * @return An input map of input events.
152
   */
153
  private Object getInputMap() {
154
    return getEditor().getProperties().get( getInputMapKey() );
155
  }
156
157
  /**
158
   * Returns the hashmap key entry for the input map.
159
   *
160
   * @return "org.fxmisc.wellbehaved.event.inputmap"
161
   */
162
  private String getInputMapKey() {
163
    return "org.fxmisc.wellbehaved.event.inputmap";
164
  }
165
166
  public void scrollToTop() {
167
    getEditor().moveTo( 0 );
168
  }
169
170
  private void setEditor( StyleClassedTextArea textArea ) {
171
    this.editor = textArea;
172
  }
173
174
  public synchronized StyleClassedTextArea getEditor() {
175
    if( this.editor == null ) {
176
      setEditor( createTextArea() );
177
    }
178
179
    return this.editor;
180
  }
181
182
  /**
183
   * Returns the scroll pane that contains the text area.
184
   *
185
   * @return The scroll pane that contains the content to edit.
186
   */
187
  public synchronized VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
188
    if( this.scrollPane == null ) {
189
      this.scrollPane = createScrollPane();
190
    }
191
192
    return this.scrollPane;
193
  }
194
195
  protected VirtualizedScrollPane<StyleClassedTextArea> createScrollPane() {
196
    final VirtualizedScrollPane<StyleClassedTextArea> pane = new VirtualizedScrollPane<>( getEditor() );
197
    pane.setVbarPolicy( ScrollPane.ScrollBarPolicy.ALWAYS );
198
199
    return pane;
200
  }
201
202
  protected StyleClassedTextArea createTextArea() {
203
    return new StyleClassedTextArea( false );
204
  }
205
206
  public Path getPath() {
207
    return this.path.get();
208
  }
209
210
  public void setPath( final Path path ) {
211
    this.path.set( path );
212
  }
213
214
  public ObjectProperty<Path> pathProperty() {
215
    return this.path;
216
  }
217
}
2181
D src/main/java/com/scrivenvar/editor/HyperlinkModel.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.editor;
29
30
import com.vladsch.flexmark.ast.Link;
31
32
/**
33
 * Represents the model for a hyperlink: text and url text.
34
 *
35
 * @author White Magic Software, Ltd.
36
 */
37
public class HyperlinkModel {
38
39
  private String text;
40
  private String url;
41
  private String title;
42
43
  /**
44
   * Constructs a new hyperlink model in Markdown format by default with no
45
   * title (i.e., tooltip).
46
   *
47
   * @param text The hyperlink text displayed (e.g., displayed to the user).
48
   * @param url The destination URL (e.g., when clicked).
49
   */
50
  public HyperlinkModel( final String text, final String url ) {
51
    this( text, url, null );
52
  }
53
54
  /**
55
   * Constructs a new hyperlink model for the given AST link.
56
   * 
57
   * @param link A markdown link.
58
   */
59
  public HyperlinkModel( final Link link ) {
60
    this(
61
      link.getText().toString(),
62
      link.getUrl().toString(),
63
      link.getTitle().toString()
64
    );
65
  }
66
67
  /**
68
   * Constructs a new hyperlink model in Markdown format by default.
69
   *
70
   * @param text The hyperlink text displayed (e.g., displayed to the user).
71
   * @param url The destination URL (e.g., when clicked).
72
   * @param title The hyperlink title (e.g., shown as a tooltip).
73
   */
74
  public HyperlinkModel( final String text, final String url, final String title ) {
75
    setText( text );
76
    setUrl( url );
77
    setTitle( title );
78
  }
79
80
  /**
81
   * Returns the string in Markdown format by default.
82
   *
83
   * @return A markdown version of the hyperlink.
84
   */
85
  @Override
86
  public String toString() {
87
    String format = "%s%s%s";
88
89
    if( hasText() ) {
90
      format = "[%s]" + (hasTitle() ? "(%s \"%s\")" : "(%s%s)");
91
    }
92
93
    // Becomes ""+URL+"" if no text is set.
94
    // Becomes [TITLE]+(URL)+"" if no title is set.
95
    // Becomes [TITLE]+(URL+ \"TITLE\") if title is set.
96
    return String.format( format, getText(), getUrl(), getTitle() );
97
  }
98
99
  public final void setText( final String text ) {
100
    this.text = nullSafe( text );
101
  }
102
103
  public final void setUrl( final String url ) {
104
    this.url = nullSafe( url );
105
  }
106
107
  public final void setTitle( final String title ) {
108
    this.title = nullSafe( title );
109
  }
110
111
  /**
112
   * Answers whether text has been set for the hyperlink.
113
   *
114
   * @return true This is a text link.
115
   */
116
  public boolean hasText() {
117
    return !getText().isEmpty();
118
  }
119
120
  /**
121
   * Answers whether a title (tooltip) has been set for the hyperlink.
122
   *
123
   * @return true There is a title.
124
   */
125
  public boolean hasTitle() {
126
    return !getTitle().isEmpty();
127
  }
128
129
  public String getText() {
130
    return this.text;
131
  }
132
133
  public String getUrl() {
134
    return this.url;
135
  }
136
137
  public String getTitle() {
138
    return this.title;
139
  }
140
141
  private String nullSafe( final String s ) {
142
    return s == null ? "" : s;
143
  }
144
}
1451
D src/main/java/com/scrivenvar/editor/LinkVisitor.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.editor;
29
30
import com.vladsch.flexmark.ast.Link;
31
import com.vladsch.flexmark.ast.Node;
32
import com.vladsch.flexmark.ast.NodeVisitor;
33
import com.vladsch.flexmark.ast.VisitHandler;
34
35
/**
36
 * @author White Magic Software, Ltd.
37
 */
38
public class LinkVisitor {
39
40
  private NodeVisitor visitor;
41
  private Link link;
42
  private final int offset;
43
44
  /**
45
   * Creates a hyperlink given an offset into a paragraph and the markdown AST
46
   * link node.
47
   *
48
   * @param index Index into the paragraph that indicates the hyperlink to
49
   * change.
50
   */
51
  public LinkVisitor( final int index ) {
52
    this.offset = index;
53
  }
54
55
  public Link process( final Node root ) {
56
    getVisitor().visit( root );
57
    return getLink();
58
  }
59
60
  /**
61
   *
62
   * @param link Not null.
63
   */
64
  private void visit( final Link link ) {
65
    final int began = link.getStartOffset();
66
    final int ended = link.getEndOffset();
67
    final int index = getOffset();
68
69
    if( index >= began && index <= ended ) {
70
      setLink( link );
71
    }
72
  }
73
74
  private synchronized NodeVisitor getVisitor() {
75
    if( this.visitor == null ) {
76
      this.visitor = createVisitor();
77
    }
78
79
    return this.visitor;
80
  }
81
82
  protected NodeVisitor createVisitor() {
83
    return new NodeVisitor(
84
      new VisitHandler<>( Link.class, LinkVisitor.this::visit ) );
85
  }
86
87
  private Link getLink() {
88
    return this.link;
89
  }
90
91
  private void setLink( final Link link ) {
92
    this.link = link;
93
  }
94
95
  public int getOffset() {
96
    return this.offset;
97
  }
98
}
991
D src/main/java/com/scrivenvar/editor/MarkdownEditorPane.java
1
/*
2
 * Copyright 2016 Karl Tauber and 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.editor;
29
30
import static com.scrivenvar.Constants.STYLESHEET_EDITOR;
31
import com.scrivenvar.dialogs.ImageDialog;
32
import com.scrivenvar.dialogs.LinkDialog;
33
import com.scrivenvar.processors.MarkdownProcessor;
34
import static com.scrivenvar.util.Utils.ltrim;
35
import static com.scrivenvar.util.Utils.rtrim;
36
import com.vladsch.flexmark.ast.Link;
37
import com.vladsch.flexmark.ast.Node;
38
import java.nio.file.Path;
39
import java.util.regex.Matcher;
40
import java.util.regex.Pattern;
41
import javafx.beans.value.ObservableValue;
42
import javafx.scene.control.Dialog;
43
import javafx.scene.control.IndexRange;
44
import static javafx.scene.input.KeyCode.ENTER;
45
import javafx.scene.input.KeyEvent;
46
import javafx.stage.Window;
47
import org.fxmisc.richtext.StyleClassedTextArea;
48
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
49
50
/**
51
 * Markdown editor pane.
52
 *
53
 * @author Karl Tauber and White Magic Software, Ltd.
54
 */
55
public class MarkdownEditorPane extends EditorPane {
56
57
  private static final Pattern AUTO_INDENT_PATTERN = Pattern.compile(
58
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
59
60
  public MarkdownEditorPane() {
61
    initEditor();
62
  }
63
64
  private void initEditor() {
65
    final StyleClassedTextArea textArea = getEditor();
66
67
    textArea.setWrapText( true );
68
    textArea.getStyleClass().add( "markdown-editor" );
69
    textArea.getStylesheets().add( STYLESHEET_EDITOR );
70
71
    addEventListener( keyPressed( ENTER ), this::enterPressed );
72
73
    // TODO: Wait for implementation that allows cutting lines, not paragraphs.
74
//    addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine );
75
  }
76
77
  public ObservableValue<String> markdownProperty() {
78
    return getEditor().textProperty();
79
  }
80
81
  private void enterPressed( final KeyEvent e ) {
82
    final StyleClassedTextArea textArea = getEditor();
83
    final String currentLine = textArea.getText( textArea.getCurrentParagraph() );
84
    final Matcher matcher = AUTO_INDENT_PATTERN.matcher( currentLine );
85
86
    String newText = "\n";
87
88
    if( matcher.matches() ) {
89
      if( !matcher.group( 2 ).isEmpty() ) {
90
        // indent new line with same whitespace characters and list markers as current line
91
        newText = newText.concat( matcher.group( 1 ) );
92
      } else {
93
        // current line contains only whitespace characters and list markers
94
        // --> empty current line
95
        final int caretPosition = textArea.getCaretPosition();
96
        textArea.selectRange( caretPosition - currentLine.length(), caretPosition );
97
      }
98
    }
99
100
    textArea.replaceSelection( newText );
101
  }
102
103
  public void surroundSelection( final String leading, final String trailing ) {
104
    surroundSelection( leading, trailing, null );
105
  }
106
107
  public void surroundSelection( String leading, String trailing, final String hint ) {
108
    final StyleClassedTextArea textArea = getEditor();
109
110
    // Note: not using textArea.insertText() to insert leading and trailing
111
    // because this would add two changes to undo history
112
    final IndexRange selection = textArea.getSelection();
113
    int start = selection.getStart();
114
    int end = selection.getEnd();
115
116
    final String selectedText = textArea.getSelectedText();
117
118
    // remove leading and trailing whitespaces from selected text
119
    String trimmedSelectedText = selectedText.trim();
120
    if( trimmedSelectedText.length() < selectedText.length() ) {
121
      start += selectedText.indexOf( trimmedSelectedText );
122
      end = start + trimmedSelectedText.length();
123
    }
124
125
    // remove leading whitespaces from leading text if selection starts at zero
126
    if( start == 0 ) {
127
      leading = ltrim( leading );
128
    }
129
130
    // remove trailing whitespaces from trailing text if selection ends at text end
131
    if( end == textArea.getLength() ) {
132
      trailing = rtrim( trailing );
133
    }
134
135
    // remove leading line separators from leading text
136
    // if there are line separators before the selected text
137
    if( leading.startsWith( "\n" ) ) {
138
      for( int i = start - 1; i >= 0 && leading.startsWith( "\n" ); i-- ) {
139
        if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) {
140
          break;
141
        }
142
        leading = leading.substring( 1 );
143
      }
144
    }
145
146
    // remove trailing line separators from trailing or leading text
147
    // if there are line separators after the selected text
148
    final boolean trailingIsEmpty = trailing.isEmpty();
149
    String str = trailingIsEmpty ? leading : trailing;
150
151
    if( str.endsWith( "\n" ) ) {
152
      final int length = textArea.getLength();
153
154
      for( int i = end; i < length && str.endsWith( "\n" ); i++ ) {
155
        if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) {
156
          break;
157
        }
158
159
        str = str.substring( 0, str.length() - 1 );
160
      }
161
162
      if( trailingIsEmpty ) {
163
        leading = str;
164
      } else {
165
        trailing = str;
166
      }
167
    }
168
169
    int selStart = start + leading.length();
170
    int selEnd = end + leading.length();
171
172
    // insert hint text if selection is empty
173
    if( hint != null && trimmedSelectedText.isEmpty() ) {
174
      trimmedSelectedText = hint;
175
      selEnd = selStart + hint.length();
176
    }
177
178
    // prevent undo merging with previous text entered by user
179
    getUndoManager().preventMerge();
180
181
    // replace text and update selection
182
    textArea.replaceText( start, end, leading + trimmedSelectedText + trailing );
183
    textArea.selectRange( selStart, selEnd );
184
  }
185
186
  /**
187
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
188
   * the markdown AST.
189
   *
190
   * @return
191
   */
192
  private HyperlinkModel getHyperlink() {
193
    final StyleClassedTextArea textArea = getEditor();
194
    final String selectedText = textArea.getSelectedText();
195
196
    // Get the current paragraph, convert to Markdown nodes.
197
    final MarkdownProcessor mp = new MarkdownProcessor( null );
198
    final int p = textArea.getCurrentParagraph();
199
    final String paragraph = textArea.getText( p );
200
    final Node node = mp.toNode( paragraph );
201
    final LinkVisitor visitor = new LinkVisitor( textArea.getCaretColumn() );
202
    final Link link = visitor.process( node );
203
204
    if( link != null ) {
205
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
206
    }
207
208
    final HyperlinkModel model = createHyperlinkModel(
209
      link, selectedText, "https://website.com"
210
    );
211
212
    return model;
213
  }
214
215
  private HyperlinkModel createHyperlinkModel(
216
    final Link link, final String selection, final String url ) {
217
218
    return link == null
219
      ? new HyperlinkModel( selection, url )
220
      : new HyperlinkModel( link );
221
  }
222
223
  private Path getParentPath() {
224
    final Path parentPath = getPath();
225
    return (parentPath != null) ? parentPath.getParent() : null;
226
  }
227
228
  private Dialog<String> createLinkDialog() {
229
    return new LinkDialog( getWindow(), getHyperlink(), getParentPath() );
230
  }
231
232
  private Dialog<String> createImageDialog() {
233
    return new ImageDialog( getWindow(), getParentPath() );
234
  }
235
236
  private void insertObject( final Dialog<String> dialog ) {
237
    dialog.showAndWait().ifPresent( result -> {
238
      getEditor().replaceSelection( result );
239
    } );
240
  }
241
242
  public void insertLink() {
243
    insertObject( createLinkDialog() );
244
  }
245
246
  public void insertImage() {
247
    insertObject( createImageDialog() );
248
  }
249
250
  private Window getWindow() {
251
    return getScrollPane().getScene().getWindow();
252
  }
253
}
2541
D src/main/java/com/scrivenvar/editor/VariableNameInjector.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.editor;
29
30
import static com.scrivenvar.Constants.SEPARATOR;
31
import com.scrivenvar.FileEditorTabPane;
32
import com.scrivenvar.Services;
33
import com.scrivenvar.decorators.VariableDecorator;
34
import com.scrivenvar.decorators.YamlVariableDecorator;
35
import com.scrivenvar.definition.DefinitionPane;
36
import static com.scrivenvar.definition.Lists.getFirst;
37
import static com.scrivenvar.definition.Lists.getLast;
38
import com.scrivenvar.service.Settings;
39
import com.scrivenvar.ui.VariableTreeItem;
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
import static com.scrivenvar.definition.Lists.getFirst;
66
import static com.scrivenvar.definition.Lists.getLast;
67
import static java.lang.Character.isSpaceChar;
68
import static java.lang.Character.isWhitespace;
69
import static java.lang.Math.min;
70
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
71
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
72
import static org.fxmisc.wellbehaved.event.InputMap.consume;
73
import static com.scrivenvar.definition.Lists.getFirst;
74
import static com.scrivenvar.definition.Lists.getLast;
75
import static java.lang.Character.isSpaceChar;
76
import static java.lang.Character.isWhitespace;
77
import static java.lang.Math.min;
78
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
79
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
80
import static org.fxmisc.wellbehaved.event.InputMap.consume;
81
import static com.scrivenvar.definition.Lists.getFirst;
82
import static com.scrivenvar.definition.Lists.getLast;
83
import static java.lang.Character.isSpaceChar;
84
import static java.lang.Character.isWhitespace;
85
import static java.lang.Math.min;
86
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
87
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
88
import static org.fxmisc.wellbehaved.event.InputMap.consume;
89
import static com.scrivenvar.definition.Lists.getFirst;
90
import static com.scrivenvar.definition.Lists.getLast;
91
import static java.lang.Character.isSpaceChar;
92
import static java.lang.Character.isWhitespace;
93
import static java.lang.Math.min;
94
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
95
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
96
import static org.fxmisc.wellbehaved.event.InputMap.consume;
97
import static com.scrivenvar.definition.Lists.getFirst;
98
import static com.scrivenvar.definition.Lists.getLast;
99
import static java.lang.Character.isSpaceChar;
100
import static java.lang.Character.isWhitespace;
101
import static java.lang.Math.min;
102
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
103
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
104
import static org.fxmisc.wellbehaved.event.InputMap.consume;
105
import static com.scrivenvar.definition.Lists.getFirst;
106
import static com.scrivenvar.definition.Lists.getLast;
107
import static java.lang.Character.isSpaceChar;
108
import static java.lang.Character.isWhitespace;
109
import static java.lang.Math.min;
110
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
111
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
112
import static org.fxmisc.wellbehaved.event.InputMap.consume;
113
import static com.scrivenvar.definition.Lists.getFirst;
114
import static com.scrivenvar.definition.Lists.getLast;
115
import static java.lang.Character.isSpaceChar;
116
import static java.lang.Character.isWhitespace;
117
import static java.lang.Math.min;
118
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
119
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
120
import static org.fxmisc.wellbehaved.event.InputMap.consume;
121
122
/**
123
 * Provides the logic for injecting variable names within the editor.
124
 *
125
 * @author White Magic Software, Ltd.
126
 */
127
public class VariableNameInjector {
128
129
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
130
131
  private static final int NO_DIFFERENCE = -1;
132
133
  private final Settings settings = Services.load( Settings.class );
134
135
  /**
136
   * Used to capture keyboard events once the user presses @.
137
   */
138
  private InputMap<InputEvent> keyboardMap;
139
140
  private FileEditorTabPane fileEditorPane;
141
  private DefinitionPane definitionPane;
142
143
  /**
144
   * Position of the variable in the text when in variable mode (0 by default).
145
   */
146
  private int initialCaretPosition;
147
148
  public VariableNameInjector(
149
    final FileEditorTabPane editorPane,
150
    final DefinitionPane definitionPane ) {
151
    setFileEditorPane( editorPane );
152
    setDefinitionPane( definitionPane );
153
154
    initKeyboardEventListeners();
155
  }
156
157
  /**
158
   * Traps keys for performing various short-cut tasks, such as @-mode variable
159
   * insertion and control+space for variable autocomplete.
160
   *
161
   * @ key is pressed, a new keyboard map is inserted in place of the current
162
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
163
   *
164
   * @see createKeyboardMap()
165
   */
166
  private void initKeyboardEventListeners() {
167
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
168
169
    // @ key in Linux?
170
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
171
    // @ key in Windows.
172
    addEventListener( keyPressed( AT ), this::vMode );
173
  }
174
175
  /**
176
   * The @ symbol is a short-cut to inserting a YAML variable reference.
177
   *
178
   * @param e Superfluous information about the key that was pressed.
179
   */
180
  private void vMode( KeyEvent e ) {
181
    setInitialCaretPosition();
182
    vModeStart();
183
    vModeAutocomplete();
184
  }
185
186
  /**
187
   * Receives key presses until the user completes the variable selection. This
188
   * allows the arrow keys to be used for selecting variables.
189
   *
190
   * @param e The key that was pressed.
191
   */
192
  private void vModeKeyPressed( KeyEvent e ) {
193
    final KeyCode keyCode = e.getCode();
194
195
    switch( keyCode ) {
196
      case BACK_SPACE:
197
        // Don't decorate the variable upon exiting vMode.
198
        vModeBackspace();
199
        break;
200
201
      case ESCAPE:
202
        // Don't decorate the variable upon exiting vMode.
203
        vModeStop();
204
        break;
205
206
      case ENTER:
207
      case PERIOD:
208
      case RIGHT:
209
      case END:
210
        // Stop at a leaf node, ENTER means accept.
211
        if( vModeConditionalComplete() && keyCode == ENTER ) {
212
          vModeStop();
213
214
          // Decorate the variable upon exiting vMode.
215
          decorateVariable();
216
        }
217
        break;
218
219
      case UP:
220
        cyclePathPrev();
221
        break;
222
223
      case DOWN:
224
        cyclePathNext();
225
        break;
226
227
      default:
228
        vModeFilterKeyPressed( e );
229
        break;
230
    }
231
232
    e.consume();
233
  }
234
235
  private void vModeBackspace() {
236
    deleteSelection();
237
238
    // Break out of variable mode by back spacing to the original position.
239
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
240
      vModeAutocomplete();
241
    } else {
242
      vModeStop();
243
    }
244
  }
245
246
  /**
247
   * Updates the text with the path selected (or typed) by the user.
248
   */
249
  private void vModeAutocomplete() {
250
    final TreeItem<String> node = getCurrentNode();
251
252
    if( !node.isLeaf() ) {
253
      final String word = getLastPathWord();
254
      final String label = node.getValue();
255
      final int delta = difference( label, word );
256
      final String remainder = delta == NO_DIFFERENCE
257
        ? label
258
        : label.substring( delta );
259
260
      final StyledTextArea textArea = getEditor();
261
      final int posBegan = getCurrentCaretPosition();
262
      final int posEnded = posBegan + remainder.length();
263
264
      textArea.replaceSelection( remainder );
265
266
      if( posEnded - posBegan > 0 ) {
267
        textArea.selectRange( posEnded, posBegan );
268
      }
269
270
      expand( node );
271
    }
272
  }
273
274
  /**
275
   * Only variable name keys can pass through the filter. This is called when
276
   * the user presses a key.
277
   *
278
   * @param e The key that was pressed.
279
   */
280
  private void vModeFilterKeyPressed( final KeyEvent e ) {
281
    if( isVariableNameKey( e ) ) {
282
      typed( e.getText() );
283
    }
284
  }
285
286
  /**
287
   * Performs an autocomplete depending on whether the user has finished typing
288
   * in a word. If there is a selected range, then this will complete the most
289
   * recent word and jump to the next child.
290
   *
291
   * @return true The auto-completed node was a terminal node.
292
   */
293
  private boolean vModeConditionalComplete() {
294
    acceptPath();
295
296
    final TreeItem<String> node = getCurrentNode();
297
    final boolean terminal = isTerminal( node );
298
299
    if( !terminal ) {
300
      typed( SEPARATOR );
301
    }
302
303
    return terminal;
304
  }
305
306
  /**
307
   * Pressing control+space will find a node that matches the current word and
308
   * substitute the YAML variable reference. This is called when the user is not
309
   * editing in vMode.
310
   *
311
   * @param e Ignored -- it can only be Ctrl+Space.
312
   */
313
  private void autocomplete( KeyEvent e ) {
314
    final String paragraph = getCaretParagraph();
315
    final int[] boundaries = getWordBoundaries( paragraph );
316
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
317
318
    final VariableTreeItem<String> leaf = findLeaf( word );
319
320
    if( leaf != null ) {
321
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
322
      decorateVariable();
323
      expand( leaf );
324
    }
325
  }
326
327
  /**
328
   * Called when autocomplete finishes on a valid leaf or when the user presses
329
   * Enter to finish manual autocomplete.
330
   */
331
  private void decorateVariable() {
332
    // A little bit of duplication...
333
    final String paragraph = getCaretParagraph();
334
    final int[] boundaries = getWordBoundaries( paragraph );
335
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
336
337
    final String newVariable = getVariableDecorator().decorate( old );
338
339
    final int posEnded = getCurrentCaretPosition();
340
    final int posBegan = posEnded - old.length();
341
342
    getEditor().replaceText( posBegan, posEnded, newVariable );
343
  }
344
345
  /**
346
   * Updates the text at the given position within the current paragraph.
347
   *
348
   * @param posBegan The starting index in the paragraph text to replace.
349
   * @param posEnded The ending index in the paragraph text to replace.
350
   * @param text Overwrite the paragraph substring with this text.
351
   */
352
  private void replaceText(
353
    final int posBegan, final int posEnded, final String text ) {
354
    final int p = getCurrentParagraph();
355
356
    getEditor().replaceText( p, posBegan, p, posEnded, text );
357
  }
358
359
  /**
360
   * Returns the caret's current paragraph position.
361
   *
362
   * @return A number greater than or equal to 0.
363
   */
364
  private int getCurrentParagraph() {
365
    return getEditor().getCurrentParagraph();
366
  }
367
368
  /**
369
   * Returns current word boundary indexes into the current paragraph, including
370
   * punctuation.
371
   *
372
   * @param p The paragraph wherein to hunt word boundaries.
373
   * @param offset The offset into the paragraph to begin scanning left and
374
   * right.
375
   *
376
   * @return The starting and ending index of the word closest to the caret.
377
   */
378
  private int[] getWordBoundaries( final String p, final int offset ) {
379
    // Remove dashes, but retain hyphens. Retain same number of characters
380
    // to preserve relative indexes.
381
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
382
383
    return getWordAt( paragraph, offset );
384
  }
385
386
  /**
387
   * Helper method to get the word boundaries for the current paragraph.
388
   *
389
   * @param paragraph
390
   *
391
   * @return
392
   */
393
  private int[] getWordBoundaries( final String paragraph ) {
394
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
395
  }
396
397
  /**
398
   * Given an arbitrary offset into a string, this returns the word at that
399
   * index. The inputs and outputs include:
400
   *
401
   * <ul>
402
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
403
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
404
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
405
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
406
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
407
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
408
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
409
   * </ul>
410
   *
411
   * @param p The string to scan for a word.
412
   * @param offset The offset within s to begin searching for the nearest word
413
   * boundary, must not be out of bounds of s.
414
   *
415
   * @return The word in s at the offset.
416
   *
417
   * @see getWordBegan( String, int )
418
   * @see getWordEnded( String, int )
419
   */
420
  private int[] getWordAt( final String p, final int offset ) {
421
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
422
  }
423
424
  /**
425
   * Returns the index into s where a word begins.
426
   *
427
   * @param s Never null.
428
   * @param offset Index into s to begin searching backwards for a word
429
   * boundary.
430
   *
431
   * @return The index where a word begins.
432
   */
433
  private int getWordBegan( final String s, int offset ) {
434
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
435
      offset--;
436
    }
437
438
    return offset;
439
  }
440
441
  /**
442
   * Returns the index into s where a word ends.
443
   *
444
   * @param s Never null.
445
   * @param offset Index into s to begin searching forwards for a word boundary.
446
   *
447
   * @return The index where a word ends.
448
   */
449
  private int getWordEnded( final String s, int offset ) {
450
    final int length = s.length();
451
452
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
453
      offset++;
454
    }
455
456
    return offset;
457
  }
458
459
  /**
460
   * Returns true if the given character can be reasonably expected to be part
461
   * of a word, including punctuation marks.
462
   *
463
   * @param c The character to compare.
464
   *
465
   * @return false The character is a space character.
466
   */
467
  private boolean isBoundary( final char c ) {
468
    return !isSpaceChar( c );
469
  }
470
471
  /**
472
   * Returns the text for the paragraph that contains the caret.
473
   *
474
   * @return A non-null string, possibly empty.
475
   */
476
  private String getCaretParagraph() {
477
    return getEditor().getText( getCurrentParagraph() );
478
  }
479
480
  /**
481
   * Returns true if the node has children that can be selected (i.e., any
482
   * non-leaves).
483
   *
484
   * @param <T> The type that the TreeItem contains.
485
   * @param node The node to test for terminality.
486
   *
487
   * @return true The node has one branch and its a leaf.
488
   */
489
  private <T> boolean isTerminal( final TreeItem<T> node ) {
490
    final ObservableList<TreeItem<T>> branches = node.getChildren();
491
492
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
493
  }
494
495
  /**
496
   * Inserts text that the user typed at the current caret position, then
497
   * performs an autocomplete for the variable name.
498
   *
499
   * @param text The text to insert, never null.
500
   */
501
  private void typed( final String text ) {
502
    getEditor().replaceSelection( text );
503
    vModeAutocomplete();
504
  }
505
506
  /**
507
   * Called when the user presses either End or Enter key.
508
   */
509
  private void acceptPath() {
510
    final IndexRange range = getSelectionRange();
511
512
    if( range != null ) {
513
      final int rangeEnd = range.getEnd();
514
      final StyledTextArea textArea = getEditor();
515
      textArea.deselect();
516
      textArea.moveTo( rangeEnd );
517
    }
518
  }
519
520
  /**
521
   * Replaces the entirety of the existing path (from the initial caret
522
   * position) with the given path.
523
   *
524
   * @param oldPath The path to replace.
525
   * @param newPath The replacement path.
526
   */
527
  private void replacePath( final String oldPath, final String newPath ) {
528
    final StyledTextArea textArea = getEditor();
529
    final int posBegan = getInitialCaretPosition();
530
    final int posEnded = posBegan + oldPath.length();
531
532
    textArea.deselect();
533
    textArea.replaceText( posBegan, posEnded, newPath );
534
  }
535
536
  /**
537
   * Called when the user presses the Backspace key.
538
   */
539
  private void deleteSelection() {
540
    final StyledTextArea textArea = getEditor();
541
    textArea.replaceSelection( "" );
542
    textArea.deletePreviousChar();
543
  }
544
545
  /**
546
   * Cycles the selected text through the nodes.
547
   *
548
   * @param direction true - next; false - previous
549
   */
550
  private void cycleSelection( final boolean direction ) {
551
    final TreeItem<String> node = getCurrentNode();
552
553
    // Find the sibling for the current selection and replace the current
554
    // selection with the sibling's value
555
    TreeItem< String> cycled = direction
556
      ? node.nextSibling()
557
      : node.previousSibling();
558
559
    // When cycling at the end (or beginning) of the list, jump to the first
560
    // (or last) sibling depending on the cycle direction.
561
    if( cycled == null ) {
562
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
563
    }
564
565
    final String path = getCurrentPath();
566
    final String cycledWord = cycled.getValue();
567
    final String word = getLastPathWord();
568
    final int index = path.indexOf( word );
569
    final String cycledPath = path.substring( 0, index ) + cycledWord;
570
571
    expand( cycled );
572
    replacePath( path, cycledPath );
573
  }
574
575
  /**
576
   * Cycles to the next sibling of the currently selected tree node.
577
   */
578
  private void cyclePathNext() {
579
    cycleSelection( true );
580
  }
581
582
  /**
583
   * Cycles to the previous sibling of the currently selected tree node.
584
   */
585
  private void cyclePathPrev() {
586
    cycleSelection( false );
587
  }
588
589
  /**
590
   * Returns the variable name (or as much as has been typed so far). Returns
591
   * all the characters from the initial caret column to the the first
592
   * whitespace character. This will return a path that contains zero or more
593
   * separators.
594
   *
595
   * @return A non-null string, possibly empty.
596
   */
597
  private String getCurrentPath() {
598
    final String s = extractTextChunk();
599
    final int length = s.length();
600
601
    int i = 0;
602
603
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
604
      i++;
605
    }
606
607
    return s.substring( 0, i );
608
  }
609
610
  private <T> ObservableList<TreeItem<T>> getSiblings(
611
    final TreeItem<T> item ) {
612
    final TreeItem<T> parent = item.getParent();
613
    return parent == null ? item.getChildren() : parent.getChildren();
614
  }
615
616
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
617
    return getFirst( getSiblings( item ), item );
618
  }
619
620
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
621
    return getLast( getSiblings( item ), item );
622
  }
623
624
  /**
625
   * Returns the caret position as an offset into the text.
626
   *
627
   * @return A value from 0 to the length of the text (minus one).
628
   */
629
  private int getCurrentCaretPosition() {
630
    return getEditor().getCaretPosition();
631
  }
632
633
  /**
634
   * Returns the caret position within the current paragraph.
635
   *
636
   * @return A value from 0 to the length of the current paragraph.
637
   */
638
  private int getCurrentCaretColumn() {
639
    return getEditor().getCaretColumn();
640
  }
641
642
  /**
643
   * Returns the last word from the path.
644
   *
645
   * @return The last token.
646
   */
647
  private String getLastPathWord() {
648
    String path = getCurrentPath();
649
650
    int i = path.indexOf( SEPARATOR );
651
652
    while( i > 0 ) {
653
      path = path.substring( i + 1 );
654
      i = path.indexOf( SEPARATOR );
655
    }
656
657
    return path;
658
  }
659
660
  /**
661
   * Returns text from the initial caret position until some arbitrarily long
662
   * number of characters. The number of characters extracted will be
663
   * getMaxVarLength, or fewer, depending on how many characters remain to be
664
   * extracted. The result from this method is trimmed to the first whitespace
665
   * character.
666
   *
667
   * @return A chunk of text that includes all the words representing a path,
668
   * and then some.
669
   */
670
  private String extractTextChunk() {
671
    final StyledTextArea textArea = getEditor();
672
    final int textBegan = getInitialCaretPosition();
673
    final int remaining = textArea.getLength() - textBegan;
674
    final int textEnded = min( remaining, getMaxVarLength() );
675
676
    return textArea.getText( textBegan, textEnded );
677
  }
678
679
  /**
680
   * Returns the node for the current path.
681
   */
682
  private TreeItem<String> getCurrentNode() {
683
    return findNode( getCurrentPath() );
684
  }
685
686
  /**
687
   * Finds the node that most closely matches the given path.
688
   *
689
   * @param path The path that represents a node.
690
   *
691
   * @return The node for the path, or the root node if the path could not be
692
   * found, but never null.
693
   */
694
  private TreeItem<String> findNode( final String path ) {
695
    return getDefinitionPane().findNode( path );
696
  }
697
698
  /**
699
   * Finds the first leaf having a value that starts with the given text.
700
   *
701
   * @param text The text to find in the definition tree.
702
   *
703
   * @return The leaf that starts with the given text, or null if not found.
704
   */
705
  private VariableTreeItem<String> findLeaf( final String text ) {
706
    return getDefinitionPane().findLeaf( text );
707
  }
708
709
  /**
710
   * Used to ignore typed keys in favour of trapping pressed keys.
711
   *
712
   * @param e The key that was typed.
713
   */
714
  private void vModeKeyTyped( KeyEvent e ) {
715
    e.consume();
716
  }
717
718
  /**
719
   * Used to lazily initialize the keyboard map.
720
   *
721
   * @return Mappings for keyTyped and keyPressed.
722
   */
723
  protected InputMap<InputEvent> createKeyboardMap() {
724
    return sequence(
725
      consume( keyTyped(), this::vModeKeyTyped ),
726
      consume( keyPressed(), this::vModeKeyPressed )
727
    );
728
  }
729
730
  private InputMap<InputEvent> getKeyboardMap() {
731
    if( this.keyboardMap == null ) {
732
      this.keyboardMap = createKeyboardMap();
733
    }
734
735
    return this.keyboardMap;
736
  }
737
738
  /**
739
   * Collapses the tree then expands and selects the given node.
740
   *
741
   * @param node The node to expand.
742
   */
743
  private void expand( final TreeItem<String> node ) {
744
    final DefinitionPane pane = getDefinitionPane();
745
    pane.collapse();
746
    pane.expand( node );
747
    pane.select( node );
748
  }
749
750
  /**
751
   * Returns true iff the key code the user typed can be used as part of a YAML
752
   * variable name.
753
   *
754
   * @param keyEvent Keyboard key press event information.
755
   *
756
   * @return true The key is a value that can be inserted into the text.
757
   */
758
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
759
    final KeyCode kc = keyEvent.getCode();
760
761
    return (kc.isLetterKey()
762
      || kc.isDigitKey()
763
      || (keyEvent.isShiftDown() && kc == MINUS))
764
      && !keyEvent.isControlDown();
765
  }
766
767
  /**
768
   * Starts to capture user input events.
769
   */
770
  private void vModeStart() {
771
    addEventListener( getKeyboardMap() );
772
  }
773
774
  /**
775
   * Restores capturing of user input events to the previous event listener.
776
   * Also asks the processing chain to modify the variable text into a
777
   * machine-readable variable based on the format required by the file type.
778
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
779
   * file (.Rmd, .Rxml) will use `r#xVAR`.
780
   */
781
  private void vModeStop() {
782
    removeEventListener( getKeyboardMap() );
783
  }
784
785
  private VariableDecorator getVariableDecorator() {
786
    return new YamlVariableDecorator();
787
  }
788
789
  /**
790
   * Returns the index where the two strings diverge.
791
   *
792
   * @param s1 The string that could be a substring of s2, null allowed.
793
   * @param s2 The string that could be a substring of s1, null allowed.
794
   *
795
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
796
   * where they differ.
797
   */
798
  @SuppressWarnings( "StringEquality" )
799
  private int difference( final CharSequence s1, final CharSequence s2 ) {
800
    if( s1 == s2 ) {
801
      return NO_DIFFERENCE;
802
    }
803
804
    if( s1 == null || s2 == null ) {
805
      return 0;
806
    }
807
808
    int i = 0;
809
    final int limit = min( s1.length(), s2.length() );
810
811
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
812
      i++;
813
    }
814
815
    // If one string was shorter than the other, that's where they differ.
816
    return i;
817
  }
818
819
  /**
820
   * Delegates to the file editor pane, and, ultimately, to its text area.
821
   */
822
  private <T extends Event, U extends T> void addEventListener(
823
    final EventPattern<? super T, ? extends U> event,
824
    final Consumer<? super U> consumer ) {
825
    getFileEditorPane().addEventListener( event, consumer );
826
  }
827
828
  /**
829
   * Delegates to the file editor pane, and, ultimately, to its text area.
830
   *
831
   * @param map The map of methods to events.
832
   */
833
  private void addEventListener( final InputMap<InputEvent> map ) {
834
    getFileEditorPane().addEventListener( map );
835
  }
836
837
  private void removeEventListener( final InputMap<InputEvent> map ) {
838
    getFileEditorPane().removeEventListener( map );
839
  }
840
841
  /**
842
   * Returns the position of the caret when variable mode editing was requested.
843
   *
844
   * @return The variable mode caret position.
845
   */
846
  private int getInitialCaretPosition() {
847
    return this.initialCaretPosition;
848
  }
849
850
  /**
851
   * Sets the position of the caret when variable mode editing was requested.
852
   * Stores the current position because only the text that comes afterwards is
853
   * a suitable variable reference.
854
   *
855
   * @return The variable mode caret position.
856
   */
857
  private void setInitialCaretPosition() {
858
    this.initialCaretPosition = getEditor().getCaretPosition();
859
  }
860
861
  private StyledTextArea getEditor() {
862
    return getFileEditorPane().getEditor();
863
  }
864
865
  public FileEditorTabPane getFileEditorPane() {
866
    return this.fileEditorPane;
867
  }
868
869
  private void setFileEditorPane( final FileEditorTabPane fileEditorPane ) {
870
    this.fileEditorPane = fileEditorPane;
871
  }
872
873
  private DefinitionPane getDefinitionPane() {
874
    return this.definitionPane;
875
  }
876
877
  private void setDefinitionPane( final DefinitionPane definitionPane ) {
878
    this.definitionPane = definitionPane;
879
  }
880
881
  private IndexRange getSelectionRange() {
882
    return getEditor().getSelection();
883
  }
884
885
  /**
886
   * Don't look ahead too far when trying to find the end of a node.
887
   *
888
   * @return 512 by default.
889
   */
890
  private int getMaxVarLength() {
891
    return getSettings().getSetting(
892
      "editor.variable.maxLength", DEFAULT_MAX_VAR_LENGTH );
893
  }
894
895
  private Settings getSettings() {
896
    return this.settings;
897
  }
898
}
8991
A src/main/java/com/scrivenvar/editors/EditorPane.java
1
/*
2
 * Copyright 2016 Karl Tauber and 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.editors;
29
30
import com.scrivenvar.AbstractPane;
31
import java.nio.file.Path;
32
import java.util.function.Consumer;
33
import javafx.application.Platform;
34
import javafx.beans.property.ObjectProperty;
35
import javafx.beans.property.SimpleObjectProperty;
36
import javafx.beans.value.ChangeListener;
37
import javafx.event.Event;
38
import javafx.scene.control.ScrollPane;
39
import javafx.scene.input.InputEvent;
40
import org.fxmisc.flowless.VirtualizedScrollPane;
41
import org.fxmisc.richtext.StyleClassedTextArea;
42
import org.fxmisc.undo.UndoManager;
43
import org.fxmisc.wellbehaved.event.EventPattern;
44
import org.fxmisc.wellbehaved.event.InputMap;
45
import static org.fxmisc.wellbehaved.event.InputMap.consume;
46
import org.fxmisc.wellbehaved.event.Nodes;
47
48
/**
49
 * Represents common editing features for various types of text editors.
50
 *
51
 * @author White Magic Software, Ltd.
52
 */
53
public class EditorPane extends AbstractPane {
54
55
  private StyleClassedTextArea editor;
56
  private VirtualizedScrollPane<StyleClassedTextArea> scrollPane;
57
  private final ObjectProperty<Path> path = new SimpleObjectProperty<>();
58
59
  /**
60
   * Set when entering variable edit mode; retrieved upon exiting.
61
   */
62
  private InputMap<InputEvent> nodeMap;
63
64
  @Override
65
  public void requestFocus() {
66
    Platform.runLater( () -> getEditor().requestFocus() );
67
  }
68
69
  public void undo() {
70
    getUndoManager().undo();
71
  }
72
73
  public void redo() {
74
    getUndoManager().redo();
75
  }
76
77
  public UndoManager getUndoManager() {
78
    return getEditor().getUndoManager();
79
  }
80
81
  public String getText() {
82
    return getEditor().getText();
83
  }
84
85
  public void setText( final String text ) {
86
    getEditor().deselect();
87
    getEditor().replaceText( text );
88
    getUndoManager().mark();
89
  }
90
91
  /**
92
   * Call to hook into changes to the text area.
93
   *
94
   * @param listener Receives editor text change events.
95
   */
96
  public void addTextChangeListener( final ChangeListener<? super String> listener ) {
97
    getEditor().textProperty().addListener( listener );
98
  }
99
100
  /**
101
   * Call to listen for when the caret moves to another paragraph.
102
   *
103
   * @param listener Receives paragraph change events.
104
   */
105
  public void addCaretParagraphListener(
106
    final ChangeListener<? super Integer> listener ) {
107
    getEditor().currentParagraphProperty().addListener( listener );
108
  }
109
110
  /**
111
   * This method adds listeners to editor events.
112
   *
113
   * @param <T> The event type.
114
   * @param <U> The consumer type for the given event type.
115
   * @param event The event of interest.
116
   * @param consumer The method to call when the event happens.
117
   */
118
  public <T extends Event, U extends T> void addEventListener(
119
    final EventPattern<? super T, ? extends U> event,
120
    final Consumer<? super U> consumer ) {
121
    Nodes.addInputMap( getEditor(), consume( event, consumer ) );
122
  }
123
124
  /**
125
   * This method adds listeners to editor events that can be removed without
126
   * affecting the original listeners (i.e., the original lister is restored on
127
   * a call to removeEventListener).
128
   *
129
   * @param map The map of methods to events.
130
   */
131
  @SuppressWarnings( "unchecked" )
132
  public void addEventListener( final InputMap<InputEvent> map ) {
133
    this.nodeMap = (InputMap<InputEvent>)getInputMap();
134
    Nodes.addInputMap( getEditor(), map );
135
  }
136
137
  /**
138
   * This method removes listeners to editor events and restores the default
139
   * handler.
140
   *
141
   * @param map The map of methods to events.
142
   */
143
  public void removeEventListener( final InputMap<InputEvent> map ) {
144
    Nodes.removeInputMap( getEditor(), map );
145
    Nodes.addInputMap( getEditor(), this.nodeMap );
146
  }
147
148
  /**
149
   * Returns the value for "org.fxmisc.wellbehaved.event.inputmap".
150
   *
151
   * @return An input map of input events.
152
   */
153
  private Object getInputMap() {
154
    return getEditor().getProperties().get( getInputMapKey() );
155
  }
156
157
  /**
158
   * Returns the hashmap key entry for the input map.
159
   *
160
   * @return "org.fxmisc.wellbehaved.event.inputmap"
161
   */
162
  private String getInputMapKey() {
163
    return "org.fxmisc.wellbehaved.event.inputmap";
164
  }
165
166
  public void scrollToTop() {
167
    getEditor().moveTo( 0 );
168
  }
169
170
  private void setEditor( StyleClassedTextArea textArea ) {
171
    this.editor = textArea;
172
  }
173
174
  public synchronized StyleClassedTextArea getEditor() {
175
    if( this.editor == null ) {
176
      setEditor( createTextArea() );
177
    }
178
179
    return this.editor;
180
  }
181
182
  /**
183
   * Returns the scroll pane that contains the text area.
184
   *
185
   * @return The scroll pane that contains the content to edit.
186
   */
187
  public synchronized VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
188
    if( this.scrollPane == null ) {
189
      this.scrollPane = createScrollPane();
190
    }
191
192
    return this.scrollPane;
193
  }
194
195
  protected VirtualizedScrollPane<StyleClassedTextArea> createScrollPane() {
196
    final VirtualizedScrollPane<StyleClassedTextArea> pane
197
      = new VirtualizedScrollPane<>( getEditor() );
198
    pane.setVbarPolicy( ScrollPane.ScrollBarPolicy.ALWAYS );
199
200
    return pane;
201
  }
202
203
  protected StyleClassedTextArea createTextArea() {
204
    return new StyleClassedTextArea( false );
205
  }
206
207
  public Path getPath() {
208
    return this.path.get();
209
  }
210
211
  public void setPath( final Path path ) {
212
    this.path.set( path );
213
  }
214
215
  public ObjectProperty<Path> pathProperty() {
216
    return this.path;
217
  }
218
}
1219
A src/main/java/com/scrivenvar/editors/VariableNameInjector.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.editors;
29
30
import com.scrivenvar.FileEditorTabPane;
31
import com.scrivenvar.Services;
32
import com.scrivenvar.decorators.VariableDecorator;
33
import com.scrivenvar.decorators.YamlVariableDecorator;
34
import com.scrivenvar.definition.DefinitionPane;
35
import com.scrivenvar.definition.VariableTreeItem;
36
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
import static com.scrivenvar.util.Lists.getFirst;
66
import static com.scrivenvar.util.Lists.getLast;
67
import static java.lang.Character.isSpaceChar;
68
import static java.lang.Character.isWhitespace;
69
import static java.lang.Math.min;
70
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
71
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
72
import static org.fxmisc.wellbehaved.event.InputMap.consume;
73
74
/**
75
 * Provides the logic for injecting variable names within the editor.
76
 *
77
 * @author White Magic Software, Ltd.
78
 */
79
public class VariableNameInjector {
80
81
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
82
83
  private static final int NO_DIFFERENCE = -1;
84
85
  private final Settings settings = Services.load( Settings.class );
86
87
  /**
88
   * Used to capture keyboard events once the user presses @.
89
   */
90
  private InputMap<InputEvent> keyboardMap;
91
92
  private FileEditorTabPane fileEditorPane;
93
  private DefinitionPane definitionPane;
94
95
  /**
96
   * Position of the variable in the text when in variable mode (0 by default).
97
   */
98
  private int initialCaretPosition;
99
100
  public VariableNameInjector(
101
    final FileEditorTabPane editorPane,
102
    final DefinitionPane definitionPane ) {
103
    setFileEditorPane( editorPane );
104
    setDefinitionPane( definitionPane );
105
106
    initKeyboardEventListeners();
107
  }
108
109
  /**
110
   * Traps keys for performing various short-cut tasks, such as @-mode variable
111
   * insertion and control+space for variable autocomplete.
112
   *
113
   * @ key is pressed, a new keyboard map is inserted in place of the current
114
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
115
   *
116
   * @see createKeyboardMap()
117
   */
118
  private void initKeyboardEventListeners() {
119
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
120
121
    // @ key in Linux?
122
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
123
    // @ key in Windows.
124
    addEventListener( keyPressed( AT ), this::vMode );
125
  }
126
127
  /**
128
   * The @ symbol is a short-cut to inserting a YAML variable reference.
129
   *
130
   * @param e Superfluous information about the key that was pressed.
131
   */
132
  private void vMode( KeyEvent e ) {
133
    setInitialCaretPosition();
134
    vModeStart();
135
    vModeAutocomplete();
136
  }
137
138
  /**
139
   * Receives key presses until the user completes the variable selection. This
140
   * allows the arrow keys to be used for selecting variables.
141
   *
142
   * @param e The key that was pressed.
143
   */
144
  private void vModeKeyPressed( KeyEvent e ) {
145
    final KeyCode keyCode = e.getCode();
146
147
    switch( keyCode ) {
148
      case BACK_SPACE:
149
        // Don't decorate the variable upon exiting vMode.
150
        vModeBackspace();
151
        break;
152
153
      case ESCAPE:
154
        // Don't decorate the variable upon exiting vMode.
155
        vModeStop();
156
        break;
157
158
      case ENTER:
159
      case PERIOD:
160
      case RIGHT:
161
      case END:
162
        // Stop at a leaf node, ENTER means accept.
163
        if( vModeConditionalComplete() && keyCode == ENTER ) {
164
          vModeStop();
165
166
          // Decorate the variable upon exiting vMode.
167
          decorateVariable();
168
        }
169
        break;
170
171
      case UP:
172
        cyclePathPrev();
173
        break;
174
175
      case DOWN:
176
        cyclePathNext();
177
        break;
178
179
      default:
180
        vModeFilterKeyPressed( e );
181
        break;
182
    }
183
184
    e.consume();
185
  }
186
187
  private void vModeBackspace() {
188
    deleteSelection();
189
190
    // Break out of variable mode by back spacing to the original position.
191
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
192
      vModeAutocomplete();
193
    } else {
194
      vModeStop();
195
    }
196
  }
197
198
  /**
199
   * Updates the text with the path selected (or typed) by the user.
200
   */
201
  private void vModeAutocomplete() {
202
    final TreeItem<String> node = getCurrentNode();
203
204
    if( !node.isLeaf() ) {
205
      final String word = getLastPathWord();
206
      final String label = node.getValue();
207
      final int delta = difference( label, word );
208
      final String remainder = delta == NO_DIFFERENCE
209
        ? label
210
        : label.substring( delta );
211
212
      final StyledTextArea textArea = getEditor();
213
      final int posBegan = getCurrentCaretPosition();
214
      final int posEnded = posBegan + remainder.length();
215
216
      textArea.replaceSelection( remainder );
217
218
      if( posEnded - posBegan > 0 ) {
219
        textArea.selectRange( posEnded, posBegan );
220
      }
221
222
      expand( node );
223
    }
224
  }
225
226
  /**
227
   * Only variable name keys can pass through the filter. This is called when
228
   * the user presses a key.
229
   *
230
   * @param e The key that was pressed.
231
   */
232
  private void vModeFilterKeyPressed( final KeyEvent e ) {
233
    if( isVariableNameKey( e ) ) {
234
      typed( e.getText() );
235
    }
236
  }
237
238
  /**
239
   * Performs an autocomplete depending on whether the user has finished typing
240
   * in a word. If there is a selected range, then this will complete the most
241
   * recent word and jump to the next child.
242
   *
243
   * @return true The auto-completed node was a terminal node.
244
   */
245
  private boolean vModeConditionalComplete() {
246
    acceptPath();
247
248
    final TreeItem<String> node = getCurrentNode();
249
    final boolean terminal = isTerminal( node );
250
251
    if( !terminal ) {
252
      typed( SEPARATOR );
253
    }
254
255
    return terminal;
256
  }
257
258
  /**
259
   * Pressing control+space will find a node that matches the current word and
260
   * substitute the YAML variable reference. This is called when the user is not
261
   * editing in vMode.
262
   *
263
   * @param e Ignored -- it can only be Ctrl+Space.
264
   */
265
  private void autocomplete( KeyEvent e ) {
266
    final String paragraph = getCaretParagraph();
267
    final int[] boundaries = getWordBoundaries( paragraph );
268
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
269
270
    final VariableTreeItem<String> leaf = findLeaf( word );
271
272
    if( leaf != null ) {
273
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
274
      decorateVariable();
275
      expand( leaf );
276
    }
277
  }
278
279
  /**
280
   * Called when autocomplete finishes on a valid leaf or when the user presses
281
   * Enter to finish manual autocomplete.
282
   */
283
  private void decorateVariable() {
284
    // A little bit of duplication...
285
    final String paragraph = getCaretParagraph();
286
    final int[] boundaries = getWordBoundaries( paragraph );
287
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
288
289
    final String newVariable = getVariableDecorator().decorate( old );
290
291
    final int posEnded = getCurrentCaretPosition();
292
    final int posBegan = posEnded - old.length();
293
294
    getEditor().replaceText( posBegan, posEnded, newVariable );
295
  }
296
297
  /**
298
   * Updates the text at the given position within the current paragraph.
299
   *
300
   * @param posBegan The starting index in the paragraph text to replace.
301
   * @param posEnded The ending index in the paragraph text to replace.
302
   * @param text Overwrite the paragraph substring with this text.
303
   */
304
  private void replaceText(
305
    final int posBegan, final int posEnded, final String text ) {
306
    final int p = getCurrentParagraph();
307
308
    getEditor().replaceText( p, posBegan, p, posEnded, text );
309
  }
310
311
  /**
312
   * Returns the caret's current paragraph position.
313
   *
314
   * @return A number greater than or equal to 0.
315
   */
316
  private int getCurrentParagraph() {
317
    return getEditor().getCurrentParagraph();
318
  }
319
320
  /**
321
   * Returns current word boundary indexes into the current paragraph, including
322
   * punctuation.
323
   *
324
   * @param p The paragraph wherein to hunt word boundaries.
325
   * @param offset The offset into the paragraph to begin scanning left and
326
   * right.
327
   *
328
   * @return The starting and ending index of the word closest to the caret.
329
   */
330
  private int[] getWordBoundaries( final String p, final int offset ) {
331
    // Remove dashes, but retain hyphens. Retain same number of characters
332
    // to preserve relative indexes.
333
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
334
335
    return getWordAt( paragraph, offset );
336
  }
337
338
  /**
339
   * Helper method to get the word boundaries for the current paragraph.
340
   *
341
   * @param paragraph
342
   *
343
   * @return
344
   */
345
  private int[] getWordBoundaries( final String paragraph ) {
346
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
347
  }
348
349
  /**
350
   * Given an arbitrary offset into a string, this returns the word at that
351
   * index. The inputs and outputs include:
352
   *
353
   * <ul>
354
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
355
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
356
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
357
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
358
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
359
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
360
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
361
   * </ul>
362
   *
363
   * @param p The string to scan for a word.
364
   * @param offset The offset within s to begin searching for the nearest word
365
   * boundary, must not be out of bounds of s.
366
   *
367
   * @return The word in s at the offset.
368
   *
369
   * @see getWordBegan( String, int )
370
   * @see getWordEnded( String, int )
371
   */
372
  private int[] getWordAt( final String p, final int offset ) {
373
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
374
  }
375
376
  /**
377
   * Returns the index into s where a word begins.
378
   *
379
   * @param s Never null.
380
   * @param offset Index into s to begin searching backwards for a word
381
   * boundary.
382
   *
383
   * @return The index where a word begins.
384
   */
385
  private int getWordBegan( final String s, int offset ) {
386
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
387
      offset--;
388
    }
389
390
    return offset;
391
  }
392
393
  /**
394
   * Returns the index into s where a word ends.
395
   *
396
   * @param s Never null.
397
   * @param offset Index into s to begin searching forwards for a word boundary.
398
   *
399
   * @return The index where a word ends.
400
   */
401
  private int getWordEnded( final String s, int offset ) {
402
    final int length = s.length();
403
404
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
405
      offset++;
406
    }
407
408
    return offset;
409
  }
410
411
  /**
412
   * Returns true if the given character can be reasonably expected to be part
413
   * of a word, including punctuation marks.
414
   *
415
   * @param c The character to compare.
416
   *
417
   * @return false The character is a space character.
418
   */
419
  private boolean isBoundary( final char c ) {
420
    return !isSpaceChar( c );
421
  }
422
423
  /**
424
   * Returns the text for the paragraph that contains the caret.
425
   *
426
   * @return A non-null string, possibly empty.
427
   */
428
  private String getCaretParagraph() {
429
    return getEditor().getText( getCurrentParagraph() );
430
  }
431
432
  /**
433
   * Returns true if the node has children that can be selected (i.e., any
434
   * non-leaves).
435
   *
436
   * @param <T> The type that the TreeItem contains.
437
   * @param node The node to test for terminality.
438
   *
439
   * @return true The node has one branch and its a leaf.
440
   */
441
  private <T> boolean isTerminal( final TreeItem<T> node ) {
442
    final ObservableList<TreeItem<T>> branches = node.getChildren();
443
444
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
445
  }
446
447
  /**
448
   * Inserts text that the user typed at the current caret position, then
449
   * performs an autocomplete for the variable name.
450
   *
451
   * @param text The text to insert, never null.
452
   */
453
  private void typed( final String text ) {
454
    getEditor().replaceSelection( text );
455
    vModeAutocomplete();
456
  }
457
458
  /**
459
   * Called when the user presses either End or Enter key.
460
   */
461
  private void acceptPath() {
462
    final IndexRange range = getSelectionRange();
463
464
    if( range != null ) {
465
      final int rangeEnd = range.getEnd();
466
      final StyledTextArea textArea = getEditor();
467
      textArea.deselect();
468
      textArea.moveTo( rangeEnd );
469
    }
470
  }
471
472
  /**
473
   * Replaces the entirety of the existing path (from the initial caret
474
   * position) with the given path.
475
   *
476
   * @param oldPath The path to replace.
477
   * @param newPath The replacement path.
478
   */
479
  private void replacePath( final String oldPath, final String newPath ) {
480
    final StyledTextArea textArea = getEditor();
481
    final int posBegan = getInitialCaretPosition();
482
    final int posEnded = posBegan + oldPath.length();
483
484
    textArea.deselect();
485
    textArea.replaceText( posBegan, posEnded, newPath );
486
  }
487
488
  /**
489
   * Called when the user presses the Backspace key.
490
   */
491
  private void deleteSelection() {
492
    final StyledTextArea textArea = getEditor();
493
    textArea.replaceSelection( "" );
494
    textArea.deletePreviousChar();
495
  }
496
497
  /**
498
   * Cycles the selected text through the nodes.
499
   *
500
   * @param direction true - next; false - previous
501
   */
502
  private void cycleSelection( final boolean direction ) {
503
    final TreeItem<String> node = getCurrentNode();
504
505
    // Find the sibling for the current selection and replace the current
506
    // selection with the sibling's value
507
    TreeItem< String> cycled = direction
508
      ? node.nextSibling()
509
      : node.previousSibling();
510
511
    // When cycling at the end (or beginning) of the list, jump to the first
512
    // (or last) sibling depending on the cycle direction.
513
    if( cycled == null ) {
514
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
515
    }
516
517
    final String path = getCurrentPath();
518
    final String cycledWord = cycled.getValue();
519
    final String word = getLastPathWord();
520
    final int index = path.indexOf( word );
521
    final String cycledPath = path.substring( 0, index ) + cycledWord;
522
523
    expand( cycled );
524
    replacePath( path, cycledPath );
525
  }
526
527
  /**
528
   * Cycles to the next sibling of the currently selected tree node.
529
   */
530
  private void cyclePathNext() {
531
    cycleSelection( true );
532
  }
533
534
  /**
535
   * Cycles to the previous sibling of the currently selected tree node.
536
   */
537
  private void cyclePathPrev() {
538
    cycleSelection( false );
539
  }
540
541
  /**
542
   * Returns the variable name (or as much as has been typed so far). Returns
543
   * all the characters from the initial caret column to the the first
544
   * whitespace character. This will return a path that contains zero or more
545
   * separators.
546
   *
547
   * @return A non-null string, possibly empty.
548
   */
549
  private String getCurrentPath() {
550
    final String s = extractTextChunk();
551
    final int length = s.length();
552
553
    int i = 0;
554
555
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
556
      i++;
557
    }
558
559
    return s.substring( 0, i );
560
  }
561
562
  private <T> ObservableList<TreeItem<T>> getSiblings(
563
    final TreeItem<T> item ) {
564
    final TreeItem<T> parent = item.getParent();
565
    return parent == null ? item.getChildren() : parent.getChildren();
566
  }
567
568
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
569
    return getFirst( getSiblings( item ), item );
570
  }
571
572
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
573
    return getLast( getSiblings( item ), item );
574
  }
575
576
  /**
577
   * Returns the caret position as an offset into the text.
578
   *
579
   * @return A value from 0 to the length of the text (minus one).
580
   */
581
  private int getCurrentCaretPosition() {
582
    return getEditor().getCaretPosition();
583
  }
584
585
  /**
586
   * Returns the caret position within the current paragraph.
587
   *
588
   * @return A value from 0 to the length of the current paragraph.
589
   */
590
  private int getCurrentCaretColumn() {
591
    return getEditor().getCaretColumn();
592
  }
593
594
  /**
595
   * Returns the last word from the path.
596
   *
597
   * @return The last token.
598
   */
599
  private String getLastPathWord() {
600
    String path = getCurrentPath();
601
602
    int i = path.indexOf( SEPARATOR );
603
604
    while( i > 0 ) {
605
      path = path.substring( i + 1 );
606
      i = path.indexOf( SEPARATOR );
607
    }
608
609
    return path;
610
  }
611
612
  /**
613
   * Returns text from the initial caret position until some arbitrarily long
614
   * number of characters. The number of characters extracted will be
615
   * getMaxVarLength, or fewer, depending on how many characters remain to be
616
   * extracted. The result from this method is trimmed to the first whitespace
617
   * character.
618
   *
619
   * @return A chunk of text that includes all the words representing a path,
620
   * and then some.
621
   */
622
  private String extractTextChunk() {
623
    final StyledTextArea textArea = getEditor();
624
    final int textBegan = getInitialCaretPosition();
625
    final int remaining = textArea.getLength() - textBegan;
626
    final int textEnded = min( remaining, getMaxVarLength() );
627
628
    return textArea.getText( textBegan, textEnded );
629
  }
630
631
  /**
632
   * Returns the node for the current path.
633
   */
634
  private TreeItem<String> getCurrentNode() {
635
    return findNode( getCurrentPath() );
636
  }
637
638
  /**
639
   * Finds the node that most closely matches the given path.
640
   *
641
   * @param path The path that represents a node.
642
   *
643
   * @return The node for the path, or the root node if the path could not be
644
   * found, but never null.
645
   */
646
  private TreeItem<String> findNode( final String path ) {
647
    return getDefinitionPane().findNode( path );
648
  }
649
650
  /**
651
   * Finds the first leaf having a value that starts with the given text.
652
   *
653
   * @param text The text to find in the definition tree.
654
   *
655
   * @return The leaf that starts with the given text, or null if not found.
656
   */
657
  private VariableTreeItem<String> findLeaf( final String text ) {
658
    return getDefinitionPane().findLeaf( text );
659
  }
660
661
  /**
662
   * Used to ignore typed keys in favour of trapping pressed keys.
663
   *
664
   * @param e The key that was typed.
665
   */
666
  private void vModeKeyTyped( KeyEvent e ) {
667
    e.consume();
668
  }
669
670
  /**
671
   * Used to lazily initialize the keyboard map.
672
   *
673
   * @return Mappings for keyTyped and keyPressed.
674
   */
675
  protected InputMap<InputEvent> createKeyboardMap() {
676
    return sequence(
677
      consume( keyTyped(), this::vModeKeyTyped ),
678
      consume( keyPressed(), this::vModeKeyPressed )
679
    );
680
  }
681
682
  private InputMap<InputEvent> getKeyboardMap() {
683
    if( this.keyboardMap == null ) {
684
      this.keyboardMap = createKeyboardMap();
685
    }
686
687
    return this.keyboardMap;
688
  }
689
690
  /**
691
   * Collapses the tree then expands and selects the given node.
692
   *
693
   * @param node The node to expand.
694
   */
695
  private void expand( final TreeItem<String> node ) {
696
    final DefinitionPane pane = getDefinitionPane();
697
    pane.collapse();
698
    pane.expand( node );
699
    pane.select( node );
700
  }
701
702
  /**
703
   * Returns true iff the key code the user typed can be used as part of a YAML
704
   * variable name.
705
   *
706
   * @param keyEvent Keyboard key press event information.
707
   *
708
   * @return true The key is a value that can be inserted into the text.
709
   */
710
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
711
    final KeyCode kc = keyEvent.getCode();
712
713
    return (kc.isLetterKey()
714
      || kc.isDigitKey()
715
      || (keyEvent.isShiftDown() && kc == MINUS))
716
      && !keyEvent.isControlDown();
717
  }
718
719
  /**
720
   * Starts to capture user input events.
721
   */
722
  private void vModeStart() {
723
    addEventListener( getKeyboardMap() );
724
  }
725
726
  /**
727
   * Restores capturing of user input events to the previous event listener.
728
   * Also asks the processing chain to modify the variable text into a
729
   * machine-readable variable based on the format required by the file type.
730
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
731
   * file (.Rmd, .Rxml) will use `r#xVAR`.
732
   */
733
  private void vModeStop() {
734
    removeEventListener( getKeyboardMap() );
735
  }
736
737
  private VariableDecorator getVariableDecorator() {
738
    return new YamlVariableDecorator();
739
  }
740
741
  /**
742
   * Returns the index where the two strings diverge.
743
   *
744
   * @param s1 The string that could be a substring of s2, null allowed.
745
   * @param s2 The string that could be a substring of s1, null allowed.
746
   *
747
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
748
   * where they differ.
749
   */
750
  @SuppressWarnings( "StringEquality" )
751
  private int difference( final CharSequence s1, final CharSequence s2 ) {
752
    if( s1 == s2 ) {
753
      return NO_DIFFERENCE;
754
    }
755
756
    if( s1 == null || s2 == null ) {
757
      return 0;
758
    }
759
760
    int i = 0;
761
    final int limit = min( s1.length(), s2.length() );
762
763
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
764
      i++;
765
    }
766
767
    // If one string was shorter than the other, that's where they differ.
768
    return i;
769
  }
770
771
  /**
772
   * Delegates to the file editor pane, and, ultimately, to its text area.
773
   */
774
  private <T extends Event, U extends T> void addEventListener(
775
    final EventPattern<? super T, ? extends U> event,
776
    final Consumer<? super U> consumer ) {
777
    getFileEditorPane().addEventListener( event, consumer );
778
  }
779
780
  /**
781
   * Delegates to the file editor pane, and, ultimately, to its text area.
782
   *
783
   * @param map The map of methods to events.
784
   */
785
  private void addEventListener( final InputMap<InputEvent> map ) {
786
    getFileEditorPane().addEventListener( map );
787
  }
788
789
  private void removeEventListener( final InputMap<InputEvent> map ) {
790
    getFileEditorPane().removeEventListener( map );
791
  }
792
793
  /**
794
   * Returns the position of the caret when variable mode editing was requested.
795
   *
796
   * @return The variable mode caret position.
797
   */
798
  private int getInitialCaretPosition() {
799
    return this.initialCaretPosition;
800
  }
801
802
  /**
803
   * Sets the position of the caret when variable mode editing was requested.
804
   * Stores the current position because only the text that comes afterwards is
805
   * a suitable variable reference.
806
   *
807
   * @return The variable mode caret position.
808
   */
809
  private void setInitialCaretPosition() {
810
    this.initialCaretPosition = getEditor().getCaretPosition();
811
  }
812
813
  private StyledTextArea getEditor() {
814
    return getFileEditorPane().getEditor();
815
  }
816
817
  public FileEditorTabPane getFileEditorPane() {
818
    return this.fileEditorPane;
819
  }
820
821
  private void setFileEditorPane( final FileEditorTabPane fileEditorPane ) {
822
    this.fileEditorPane = fileEditorPane;
823
  }
824
825
  private DefinitionPane getDefinitionPane() {
826
    return this.definitionPane;
827
  }
828
829
  private void setDefinitionPane( final DefinitionPane definitionPane ) {
830
    this.definitionPane = definitionPane;
831
  }
832
833
  private IndexRange getSelectionRange() {
834
    return getEditor().getSelection();
835
  }
836
837
  /**
838
   * Don't look ahead too far when trying to find the end of a node.
839
   *
840
   * @return 512 by default.
841
   */
842
  private int getMaxVarLength() {
843
    return getSettings().getSetting(
844
      "editor.variable.maxLength", DEFAULT_MAX_VAR_LENGTH );
845
  }
846
847
  private Settings getSettings() {
848
    return this.settings;
849
  }
850
}
1851
A src/main/java/com/scrivenvar/editors/markdown/HyperlinkModel.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.editors.markdown;
29
30
import com.vladsch.flexmark.ast.Link;
31
32
/**
33
 * Represents the model for a hyperlink: text and url text.
34
 *
35
 * @author White Magic Software, Ltd.
36
 */
37
public class HyperlinkModel {
38
39
  private String text;
40
  private String url;
41
  private String title;
42
43
  /**
44
   * Constructs a new hyperlink model in Markdown format by default with no
45
   * title (i.e., tooltip).
46
   *
47
   * @param text The hyperlink text displayed (e.g., displayed to the user).
48
   * @param url The destination URL (e.g., when clicked).
49
   */
50
  public HyperlinkModel( final String text, final String url ) {
51
    this( text, url, null );
52
  }
53
54
  /**
55
   * Constructs a new hyperlink model for the given AST link.
56
   * 
57
   * @param link A markdown link.
58
   */
59
  public HyperlinkModel( final Link link ) {
60
    this(
61
      link.getText().toString(),
62
      link.getUrl().toString(),
63
      link.getTitle().toString()
64
    );
65
  }
66
67
  /**
68
   * Constructs a new hyperlink model in Markdown format by default.
69
   *
70
   * @param text The hyperlink text displayed (e.g., displayed to the user).
71
   * @param url The destination URL (e.g., when clicked).
72
   * @param title The hyperlink title (e.g., shown as a tooltip).
73
   */
74
  public HyperlinkModel( final String text, final String url, final String title ) {
75
    setText( text );
76
    setUrl( url );
77
    setTitle( title );
78
  }
79
80
  /**
81
   * Returns the string in Markdown format by default.
82
   *
83
   * @return A markdown version of the hyperlink.
84
   */
85
  @Override
86
  public String toString() {
87
    String format = "%s%s%s";
88
89
    if( hasText() ) {
90
      format = "[%s]" + (hasTitle() ? "(%s \"%s\")" : "(%s%s)");
91
    }
92
93
    // Becomes ""+URL+"" if no text is set.
94
    // Becomes [TITLE]+(URL)+"" if no title is set.
95
    // Becomes [TITLE]+(URL+ \"TITLE\") if title is set.
96
    return String.format( format, getText(), getUrl(), getTitle() );
97
  }
98
99
  public final void setText( final String text ) {
100
    this.text = nullSafe( text );
101
  }
102
103
  public final void setUrl( final String url ) {
104
    this.url = nullSafe( url );
105
  }
106
107
  public final void setTitle( final String title ) {
108
    this.title = nullSafe( title );
109
  }
110
111
  /**
112
   * Answers whether text has been set for the hyperlink.
113
   *
114
   * @return true This is a text link.
115
   */
116
  public boolean hasText() {
117
    return !getText().isEmpty();
118
  }
119
120
  /**
121
   * Answers whether a title (tooltip) has been set for the hyperlink.
122
   *
123
   * @return true There is a title.
124
   */
125
  public boolean hasTitle() {
126
    return !getTitle().isEmpty();
127
  }
128
129
  public String getText() {
130
    return this.text;
131
  }
132
133
  public String getUrl() {
134
    return this.url;
135
  }
136
137
  public String getTitle() {
138
    return this.title;
139
  }
140
141
  private String nullSafe( final String s ) {
142
    return s == null ? "" : s;
143
  }
144
}
1145
A src/main/java/com/scrivenvar/editors/markdown/LinkVisitor.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.editors.markdown;
29
30
import com.vladsch.flexmark.ast.Link;
31
import com.vladsch.flexmark.ast.Node;
32
import com.vladsch.flexmark.ast.NodeVisitor;
33
import com.vladsch.flexmark.ast.VisitHandler;
34
35
/**
36
 * @author White Magic Software, Ltd.
37
 */
38
public class LinkVisitor {
39
40
  private NodeVisitor visitor;
41
  private Link link;
42
  private final int offset;
43
44
  /**
45
   * Creates a hyperlink given an offset into a paragraph and the markdown AST
46
   * link node.
47
   *
48
   * @param index Index into the paragraph that indicates the hyperlink to
49
   * change.
50
   */
51
  public LinkVisitor( final int index ) {
52
    this.offset = index;
53
  }
54
55
  public Link process( final Node root ) {
56
    getVisitor().visit( root );
57
    return getLink();
58
  }
59
60
  /**
61
   *
62
   * @param link Not null.
63
   */
64
  private void visit( final Link link ) {
65
    final int began = link.getStartOffset();
66
    final int ended = link.getEndOffset();
67
    final int index = getOffset();
68
69
    if( index >= began && index <= ended ) {
70
      setLink( link );
71
    }
72
  }
73
74
  private synchronized NodeVisitor getVisitor() {
75
    if( this.visitor == null ) {
76
      this.visitor = createVisitor();
77
    }
78
79
    return this.visitor;
80
  }
81
82
  protected NodeVisitor createVisitor() {
83
    return new NodeVisitor(
84
      new VisitHandler<>( Link.class, LinkVisitor.this::visit ) );
85
  }
86
87
  private Link getLink() {
88
    return this.link;
89
  }
90
91
  private void setLink( final Link link ) {
92
    this.link = link;
93
  }
94
95
  public int getOffset() {
96
    return this.offset;
97
  }
98
}
199
A src/main/java/com/scrivenvar/editors/markdown/MarkdownEditorPane.java
1
/*
2
 * Copyright 2016 Karl Tauber and 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.editors.markdown;
29
30
import com.scrivenvar.dialogs.ImageDialog;
31
import com.scrivenvar.dialogs.LinkDialog;
32
import com.scrivenvar.editors.EditorPane;
33
import com.scrivenvar.processors.MarkdownProcessor;
34
import static com.scrivenvar.util.Utils.ltrim;
35
import static com.scrivenvar.util.Utils.rtrim;
36
import com.vladsch.flexmark.ast.Link;
37
import com.vladsch.flexmark.ast.Node;
38
import java.nio.file.Path;
39
import java.util.regex.Matcher;
40
import java.util.regex.Pattern;
41
import javafx.beans.value.ObservableValue;
42
import javafx.scene.control.Dialog;
43
import javafx.scene.control.IndexRange;
44
import static javafx.scene.input.KeyCode.ENTER;
45
import javafx.scene.input.KeyEvent;
46
import javafx.stage.Window;
47
import org.fxmisc.richtext.StyleClassedTextArea;
48
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
49
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
50
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
51
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
52
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
53
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
54
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
55
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
56
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
57
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
58
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
59
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
60
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
61
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
62
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
63
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
64
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
65
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
66
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
67
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
68
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
69
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
70
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
71
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
72
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
73
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
74
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
75
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
76
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
77
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
78
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
79
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
80
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
81
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
82
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
83
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
84
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
85
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
86
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
87
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
88
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
89
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
90
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
91
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
92
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
93
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
94
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
95
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
96
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
97
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
98
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
99
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
100
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
101
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
102
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
103
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
104
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
105
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
106
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
107
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
108
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
109
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
110
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
111
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
112
import static com.scrivenvar.Constants.STYLESHEET_MARKDOWN;
113
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
114
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
115
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
116
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
117
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
118
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
119
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
120
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
121
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
122
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
123
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
124
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
125
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
126
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
127
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
128
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
129
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
130
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
131
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
132
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
133
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
134
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
135
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
136
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
137
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
138
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
139
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
140
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
141
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
142
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
143
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
144
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
145
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
146
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
147
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
148
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
149
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
150
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
151
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
152
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
153
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
154
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
155
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
156
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
157
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
158
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
159
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
160
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
161
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
162
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
163
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
164
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
165
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
166
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
167
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
168
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
169
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
170
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
171
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
172
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
173
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
174
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
175
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
176
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
177
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
178
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
179
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
180
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
181
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
182
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
183
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
184
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
185
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
186
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
187
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
188
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
189
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
190
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
191
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
192
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
193
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
194
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
195
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
196
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
197
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
198
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
199
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
200
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
201
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
202
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
203
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
204
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
205
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
206
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
207
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
208
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
209
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
210
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
211
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
212
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
213
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
214
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
215
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
216
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
217
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
218
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
219
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
220
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
221
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
222
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
223
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
224
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
225
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
226
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
227
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
228
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
229
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
230
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
231
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
232
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
233
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
234
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
235
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
236
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
237
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
238
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
239
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
240
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
241
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
242
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
243
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
244
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
245
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
246
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
247
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
248
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
249
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
250
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
251
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
252
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
253
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
254
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
255
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
256
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
257
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
258
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
259
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
260
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
261
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
262
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
263
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
264
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
265
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
266
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
267
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
268
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
269
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
270
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
271
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
272
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
273
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
274
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
275
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
276
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
277
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
278
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
279
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
280
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
281
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
282
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
283
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
284
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
285
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
286
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
287
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
288
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
289
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
290
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
291
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
292
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
293
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
294
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
295
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
296
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
297
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
298
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
299
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
300
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
301
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
302
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
303
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
304
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
305
306
/**
307
 * Markdown editor pane.
308
 *
309
 * @author Karl Tauber and White Magic Software, Ltd.
310
 */
311
public class MarkdownEditorPane extends EditorPane {
312
313
  private static final Pattern AUTO_INDENT_PATTERN = Pattern.compile(
314
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
315
316
  public MarkdownEditorPane() {
317
    initEditor();
318
  }
319
320
  private void initEditor() {
321
    final StyleClassedTextArea textArea = getEditor();
322
323
    textArea.setWrapText( true );
324
    textArea.getStyleClass().add( "markdown-editor" );
325
    textArea.getStylesheets().add(STYLESHEET_MARKDOWN );
326
327
    addEventListener( keyPressed( ENTER ), this::enterPressed );
328
329
    // TODO: Wait for implementation that allows cutting lines, not paragraphs.
330
//    addEventListener( keyPressed( X, SHORTCUT_DOWN ), this::cutLine );
331
  }
332
333
  public ObservableValue<String> markdownProperty() {
334
    return getEditor().textProperty();
335
  }
336
337
  private void enterPressed( final KeyEvent e ) {
338
    final StyleClassedTextArea textArea = getEditor();
339
    final String currentLine = textArea.getText( textArea.getCurrentParagraph() );
340
    final Matcher matcher = AUTO_INDENT_PATTERN.matcher( currentLine );
341
342
    String newText = "\n";
343
344
    if( matcher.matches() ) {
345
      if( !matcher.group( 2 ).isEmpty() ) {
346
        // indent new line with same whitespace characters and list markers as current line
347
        newText = newText.concat( matcher.group( 1 ) );
348
      } else {
349
        // current line contains only whitespace characters and list markers
350
        // --> empty current line
351
        final int caretPosition = textArea.getCaretPosition();
352
        textArea.selectRange( caretPosition - currentLine.length(), caretPosition );
353
      }
354
    }
355
356
    textArea.replaceSelection( newText );
357
  }
358
359
  public void surroundSelection( final String leading, final String trailing ) {
360
    surroundSelection( leading, trailing, null );
361
  }
362
363
  public void surroundSelection( String leading, String trailing, final String hint ) {
364
    final StyleClassedTextArea textArea = getEditor();
365
366
    // Note: not using textArea.insertText() to insert leading and trailing
367
    // because this would add two changes to undo history
368
    final IndexRange selection = textArea.getSelection();
369
    int start = selection.getStart();
370
    int end = selection.getEnd();
371
372
    final String selectedText = textArea.getSelectedText();
373
374
    // remove leading and trailing whitespaces from selected text
375
    String trimmedSelectedText = selectedText.trim();
376
    if( trimmedSelectedText.length() < selectedText.length() ) {
377
      start += selectedText.indexOf( trimmedSelectedText );
378
      end = start + trimmedSelectedText.length();
379
    }
380
381
    // remove leading whitespaces from leading text if selection starts at zero
382
    if( start == 0 ) {
383
      leading = ltrim( leading );
384
    }
385
386
    // remove trailing whitespaces from trailing text if selection ends at text end
387
    if( end == textArea.getLength() ) {
388
      trailing = rtrim( trailing );
389
    }
390
391
    // remove leading line separators from leading text
392
    // if there are line separators before the selected text
393
    if( leading.startsWith( "\n" ) ) {
394
      for( int i = start - 1; i >= 0 && leading.startsWith( "\n" ); i-- ) {
395
        if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) {
396
          break;
397
        }
398
        leading = leading.substring( 1 );
399
      }
400
    }
401
402
    // remove trailing line separators from trailing or leading text
403
    // if there are line separators after the selected text
404
    final boolean trailingIsEmpty = trailing.isEmpty();
405
    String str = trailingIsEmpty ? leading : trailing;
406
407
    if( str.endsWith( "\n" ) ) {
408
      final int length = textArea.getLength();
409
410
      for( int i = end; i < length && str.endsWith( "\n" ); i++ ) {
411
        if( !"\n".equals( textArea.getText( i, i + 1 ) ) ) {
412
          break;
413
        }
414
415
        str = str.substring( 0, str.length() - 1 );
416
      }
417
418
      if( trailingIsEmpty ) {
419
        leading = str;
420
      } else {
421
        trailing = str;
422
      }
423
    }
424
425
    int selStart = start + leading.length();
426
    int selEnd = end + leading.length();
427
428
    // insert hint text if selection is empty
429
    if( hint != null && trimmedSelectedText.isEmpty() ) {
430
      trimmedSelectedText = hint;
431
      selEnd = selStart + hint.length();
432
    }
433
434
    // prevent undo merging with previous text entered by user
435
    getUndoManager().preventMerge();
436
437
    // replace text and update selection
438
    textArea.replaceText( start, end, leading + trimmedSelectedText + trailing );
439
    textArea.selectRange( selStart, selEnd );
440
  }
441
442
  /**
443
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
444
   * the markdown AST.
445
   *
446
   * @return
447
   */
448
  private HyperlinkModel getHyperlink() {
449
    final StyleClassedTextArea textArea = getEditor();
450
    final String selectedText = textArea.getSelectedText();
451
452
    // Get the current paragraph, convert to Markdown nodes.
453
    final MarkdownProcessor mp = new MarkdownProcessor( null );
454
    final int p = textArea.getCurrentParagraph();
455
    final String paragraph = textArea.getText( p );
456
    final Node node = mp.toNode( paragraph );
457
    final LinkVisitor visitor = new LinkVisitor( textArea.getCaretColumn() );
458
    final Link link = visitor.process( node );
459
460
    if( link != null ) {
461
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
462
    }
463
464
    final HyperlinkModel model = createHyperlinkModel(
465
      link, selectedText, "https://website.com"
466
    );
467
468
    return model;
469
  }
470
471
  private HyperlinkModel createHyperlinkModel(
472
    final Link link, final String selection, final String url ) {
473
474
    return link == null
475
      ? new HyperlinkModel( selection, url )
476
      : new HyperlinkModel( link );
477
  }
478
479
  private Path getParentPath() {
480
    final Path parentPath = getPath();
481
    return (parentPath != null) ? parentPath.getParent() : null;
482
  }
483
484
  private Dialog<String> createLinkDialog() {
485
    return new LinkDialog( getWindow(), getHyperlink(), getParentPath() );
486
  }
487
488
  private Dialog<String> createImageDialog() {
489
    return new ImageDialog( getWindow(), getParentPath() );
490
  }
491
492
  private void insertObject( final Dialog<String> dialog ) {
493
    dialog.showAndWait().ifPresent( result -> {
494
      getEditor().replaceSelection( result );
495
    } );
496
  }
497
498
  public void insertLink() {
499
    insertObject( createLinkDialog() );
500
  }
501
502
  public void insertImage() {
503
    insertObject( createImageDialog() );
504
  }
505
506
  private Window getWindow() {
507
    return getScrollPane().getScene().getWindow();
508
  }
509
}
1510
M src/main/java/com/scrivenvar/predicates/strings/StartsPredicate.java
3636
3737
  /**
38
   * Calls the superclass to construct the instance.
38
   * Constructs a new instance using a comparate that will be compared with
39
   * the comparator during the test.
3940
   *
40
   * @param comparate Not null.
41
   * @param comparate The string to compare against the comparator.
4142
   */
4243
  public StartsPredicate( final String comparate ) {
...
4950
   * @param comparator A non-null string, possibly empty.
5051
   *
51
   * @return true The strings are equal, ignoring case.
52
   * @return true The comparator starts with the comparate, ignoring case.
5253
   */
5354
  @Override
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
2828
package com.scrivenvar.preview;
2929
30
import static com.scrivenvar.Constants.CARET_POSITION;
30
import static com.scrivenvar.Constants.CARET_POSITION_BASE;
31
import static com.scrivenvar.Constants.STYLESHEET_PREVIEW;
3132
import java.nio.file.Path;
3233
import javafx.beans.value.ObservableValue;
...
99100
      + "<html>"
100101
      + "<head>"
101
      + "<link rel='stylesheet' href='" + getClass().getResource( "webview.css" ) + "'>"
102
      + "<link rel='stylesheet' href='" + getClass().getResource( STYLESHEET_PREVIEW ) + "'>"
102103
      + getBase()
103104
      + "</head>"
104105
      + "<body>"
105106
      + html
106107
      + "</body>"
107108
      + "</html>" );
109
  }
110
111
  /**
112
   * Clears out the HTML content from the preview.
113
   */
114
  public void clear() {
115
    update( "" );
108116
  }
109117
...
122130
  private String getScrollScript() {
123131
    return ""
124
      + "var e = document.getElementById('" + CARET_POSITION + "');"
132
      + "var e = document.getElementById('" + CARET_POSITION_BASE + "');"
125133
      + "if( e != null ) { "
126134
      + "  Element.prototype.topOffset = function () {"
...
157165
    this.path = path;
158166
  }
159
  
167
160168
  /**
161169
   * Content to embed in a panel.
162
   * 
170
   *
163171
   * @return The content to display to the user.
164172
   */
M src/main/java/com/scrivenvar/processors/MarkdownCaretInsertionProcessor.java
2828
package com.scrivenvar.processors;
2929
30
import static com.scrivenvar.Constants.MD_CARET_POSITION;
30
import static com.scrivenvar.Constants.CARET_POSITION_MD;
3131
import static java.lang.Character.isLetter;
3232
3333
/**
34
 * Responsible for inserting the magic CARET POSITION into the markdown so
35
 * that, upon rendering into HTML, the HTML pane can scroll to the correct
36
 * position (relative to the caret position in the editor).
34
 * Responsible for inserting the magic CARET POSITION into the markdown so that,
35
 * upon rendering into HTML, the HTML pane can scroll to the correct position
36
 * (relative to the caret position in the editor).
3737
 *
3838
 * @author White Magic Software, Ltd.
...
7373
      offset++;
7474
    }
75
    
75
7676
    // TODO: Ensure that the caret position is outside of an element, 
7777
    // so that a caret inserted in the image doesn't corrupt it. Such as:
7878
    //
7979
    // ![Screenshot](images/scr|eenshot.png)
80
8180
    // Insert the caret position into the Markdown text, but don't interfere
8281
    // with the Markdown iteself.
8382
    return new StringBuilder( t ).replace(
84
      offset, offset, MD_CARET_POSITION ).toString();
83
      offset, offset, CARET_POSITION_MD ).toString();
8584
  }
8685
M src/main/java/com/scrivenvar/processors/MarkdownCaretReplacementProcessor.java
2828
package com.scrivenvar.processors;
2929
30
import static com.scrivenvar.Constants.CARET_POSITION;
31
import static com.scrivenvar.Constants.MD_CARET_POSITION;
30
import static com.scrivenvar.Constants.CARET_POSITION_HTML;
31
import static com.scrivenvar.Constants.CARET_POSITION_MD;
3232
3333
/**
3434
 * Responsible for replacing the caret position marker with an HTML element
3535
 * suitable to use as a reference for scrolling a view port.
3636
 *
3737
 * @author White Magic Software, Ltd.
3838
 */
3939
public class MarkdownCaretReplacementProcessor extends AbstractProcessor<String> {
4040
  private static final int INDEX_NOT_FOUND = -1;
41
42
  private static final String HTML_ELEMENT
43
    = "<span id='" + CARET_POSITION + "'></span>";
4441
4542
  public MarkdownCaretReplacementProcessor( final Processor<String> processor ) {
...
5754
  @Override
5855
  public String processLink( final String t ) {
59
    return replace( t, MD_CARET_POSITION, HTML_ELEMENT );
56
    return replace(t, CARET_POSITION_MD, CARET_POSITION_HTML );
6057
  }
6158
M src/main/java/com/scrivenvar/processors/Processor.java
4242
   * 
4343
   * @param t The value to pass along to each link in the chain.
44
   * @return The value after having been processed by each link.
4544
   */
4645
  public void processChain( T t );
M src/main/java/com/scrivenvar/service/Settings.java
2828
package com.scrivenvar.service;
2929
30
import java.util.Iterator;
3031
import java.util.List;
3132
3233
/**
3334
 * Defines how settings and options can be retrieved.
34
 * 
35
 *
3536
 * @author White Magic Software, Ltd.
3637
 */
3738
public interface Settings extends Service {
3839
3940
  /**
4041
   * Returns a setting property or its default value.
4142
   *
4243
   * @param property The property key name to obtain its value.
43
   * @param defaultValue The default value to return iff the property cannot
44
   * be found.
44
   * @param defaultValue The default value to return iff the property cannot be
45
   * found.
4546
   *
4647
   * @return The property value for the given property key.
4748
   */
4849
  public String getSetting( String property, String defaultValue );
49
  
50
5051
  /**
5152
   * Returns a setting property or its default value.
5253
   *
5354
   * @param property The property key name to obtain its value.
54
   * @param defaultValue The default value to return iff the property cannot
55
   * be found.
55
   * @param defaultValue The default value to return iff the property cannot be
56
   * found.
5657
   *
5758
   * @return The property value for the given property key.
5859
   */
5960
  public int getSetting( String property, int defaultValue );
6061
6162
  /**
6263
   * Returns a setting property or its default value.
6364
   *
6465
   * @param property The property key name to obtain its value.
65
   * @param defaults The default values to return iff the property cannot
66
   * be found.
66
   * @param defaults The default values to return iff the property cannot be
67
   * found.
6768
   *
6869
   * @return The property values for the given property key.
6970
   */
7071
  public List<Object> getSettingList( String property, List<String> defaults );
7172
73
  /**
74
   * Returns a list of property names that begin with the given prefix. The
75
   * prefix is included in any matching results. This will return keys that
76
   * either match the prefix or start with the prefix followed by a dot ('.').
77
   * For example, a prefix value of <code>the.property.name</code> will likely
78
   * return the expected results, but <code>the.property.name.</code> (note the
79
   * extraneous period) will probably not.
80
   *
81
   * @param prefix The prefix to compare against each property name.
82
   *
83
   * @return The list of property names that have the given prefix.
84
   */
85
  public Iterator<String> getKeys( final String prefix );
7286
7387
  /**
...
8094
   */
8195
  public List<String> getStringSettingList( String property, List<String> defaults );
96
97
  /**
98
   * Converts the generic list of property objects into strings.
99
   *
100
   * @param property The property value to coerce.
101
   *
102
   * @return The list of properties coerced from objects to strings.
103
   */
104
  public List<String> getStringSettingList( String property );
82105
}
83106
M src/main/java/com/scrivenvar/service/impl/DefaultOptions.java
2727
package com.scrivenvar.service.impl;
2828
29
import static com.scrivenvar.Constants.PREFS_ROOT;
30
import static com.scrivenvar.Constants.PREFS_ROOT_OPTIONS;
31
import static com.scrivenvar.Constants.PREFS_ROOT_STATE;
2932
import com.scrivenvar.service.Options;
3033
import java.util.prefs.Preferences;
...
4043
  
4144
  public DefaultOptions() {
42
    setPreferences( getRootPreferences().node( "options" ) );
45
    setPreferences( getRootPreferences().node( PREFS_ROOT_OPTIONS ) );
4346
  }
4447
...
5861
5962
  private Preferences getRootPreferences() {
60
    return userRoot().node( "application" );
63
    return userRoot().node( PREFS_ROOT );
6164
  }
6265
6366
  @Override
6467
  public Preferences getState() {
65
    return getRootPreferences().node( "state" );
68
    return getRootPreferences().node( PREFS_ROOT_STATE );
6669
  }
6770
M src/main/java/com/scrivenvar/service/impl/DefaultSettings.java
3434
import java.net.URL;
3535
import java.util.ArrayList;
36
import java.util.Iterator;
3637
import java.util.List;
3738
import java.util.Objects;
...
5152
  public DefaultSettings()
5253
    throws ConfigurationException, URISyntaxException, IOException {
53
    setProperties(createProperties());
54
    setProperties( createProperties() );
5455
  }
5556
...
6364
   */
6465
  @Override
65
  public String getSetting(final String property, final String defaultValue) {
66
    return getSettings().getString(property, defaultValue);
66
  public String getSetting( final String property, final String defaultValue ) {
67
    return getSettings().getString( property, defaultValue );
6768
  }
6869
...
7677
   */
7778
  @Override
78
  public int getSetting(final String property, final int defaultValue) {
79
    return getSettings().getInt(property, defaultValue);
79
  public int getSetting( final String property, final int defaultValue ) {
80
    return getSettings().getInt( property, defaultValue );
8081
  }
8182
83
  /**
84
   * Returns a list of objects for a given setting.
85
   *
86
   * @param property The setting key name.
87
   * @param defaults The default values to return, which may be null.
88
   *
89
   * @return A list, possibly empty, never null.
90
   */
8291
  @Override
83
  public List<Object> getSettingList(final String property, List<String> defaults) {
84
    if (defaults == null) {
92
  public List<Object> getSettingList( final String property, List<String> defaults ) {
93
    if( defaults == null ) {
8594
      defaults = new ArrayList<>();
8695
    }
87
    
88
    return getSettings().getList(property, defaults);
96
97
    return getSettings().getList( property, defaults );
8998
  }
9099
...
99108
  @Override
100109
  public List<String> getStringSettingList(
101
    final String property, final List<String> defaults) {
102
    final List<Object> settings = getSettingList(property, defaults);
110
    final String property, final List<String> defaults ) {
111
    final List<Object> settings = getSettingList( property, defaults );
103112
104113
    return settings.stream()
105
      .map(object -> Objects.toString(object, null))
106
      .collect(Collectors.toList());
114
      .map( object -> Objects.toString( object, null ) )
115
      .collect( Collectors.toList() );
116
  }
117
118
  /**
119
   * Convert a list of property objects into strings, with no default value.
120
   *
121
   * @param property The property value to coerce.
122
   *
123
   * @return The list of properties coerced from objects to strings.
124
   */
125
  @Override
126
  public List<String> getStringSettingList( final String property ) {
127
    return getStringSettingList( property, null );
128
  }
129
130
  /**
131
   * Returns a list of property names that begin with the given prefix.
132
   *
133
   * @param prefix The prefix to compare against each property name.
134
   *
135
   * @return The list of property names that have the given prefix.
136
   */
137
  @Override
138
  public Iterator<String> getKeys( final String prefix ) {
139
    return getSettings().getKeys( prefix );
107140
  }
108141
109142
  private PropertiesConfiguration createProperties()
110143
    throws ConfigurationException {
111144
    final URL url = getPropertySource();
112145
113146
    return url == null
114147
      ? new PropertiesConfiguration()
115
      : new PropertiesConfiguration(url);
148
      : new PropertiesConfiguration( url );
116149
  }
117150
118151
  private URL getPropertySource() {
119
    return getClass().getResource(getSettingsFilename());
152
    return getClass().getResource( getSettingsFilename() );
120153
  }
121154
122155
  private String getSettingsFilename() {
123156
    return SETTINGS_NAME;
124157
  }
125158
126
  private void setProperties(final PropertiesConfiguration configuration) {
159
  private void setProperties( final PropertiesConfiguration configuration ) {
127160
    this.properties = configuration;
128161
  }
A src/main/java/com/scrivenvar/test/TestDefinitionPane.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.test;
29
30
import com.scrivenvar.definition.DefinitionPane;
31
import static javafx.application.Application.launch;
32
import javafx.scene.control.TreeItem;
33
import javafx.scene.control.TreeView;
34
import javafx.stage.Stage;
35
36
/**
37
 * TestDefinitionPane application for debugging.
38
 */
39
public final class TestDefinitionPane extends TestHarness {
40
  /**
41
   * Application entry point.
42
   *
43
   * @param stage The primary application stage.
44
   *
45
   * @throws Exception Could not read configuration file.
46
   */
47
  @Override
48
  public void start( final Stage stage ) throws Exception {
49
    super.start( stage );
50
51
    TreeView<String> root = createTreeView();
52
    DefinitionPane pane = createDefinitionPane( root );
53
54
    test( pane, "language.ai.", "article" );
55
    test( pane, "language.ai", "ai" );
56
    test( pane, "l", "location" );
57
    test( pane, "la", "language" );
58
    test( pane, "c.p.n", "name" );
59
    test( pane, "c.p.n.", "First" );
60
    test( pane, "...", "c" );
61
    test( pane, "foo", "c" );
62
    test( pane, "foo.bar", "c" );
63
    test( pane, "", "c" );
64
    test( pane, "c", "protagonist" );
65
    test( pane, "c.", "protagonist" );
66
    test( pane, "c.p", "protagonist" );
67
    test( pane, "c.protagonist", "protagonist" );
68
69
    System.exit( 0 );
70
  }
71
72
  private void test( DefinitionPane pane, String path, String value ) {
73
    System.out.println( "---------------------------" );
74
    System.out.println( "Find Path: '" + path + "'" );
75
    final TreeItem<String> node = pane.findNode( path );
76
    System.out.println( "Path Node: " + node );
77
    System.out.println( "Node Val : " + node.getValue() );
78
  }
79
80
  public static void main( String[] args ) {
81
    launch( args );
82
  }
83
}
184
A src/main/java/com/scrivenvar/test/TestHarness.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.test;
29
30
import static com.scrivenvar.Messages.get;
31
import com.scrivenvar.definition.DefinitionPane;
32
import com.scrivenvar.definition.yaml.YamlParser;
33
import com.scrivenvar.definition.yaml.YamlTreeAdapter;
34
import java.io.IOException;
35
import java.io.InputStream;
36
import javafx.application.Application;
37
import javafx.scene.Scene;
38
import javafx.scene.control.TreeView;
39
import javafx.scene.layout.BorderPane;
40
import javafx.stage.Stage;
41
import org.fxmisc.flowless.VirtualizedScrollPane;
42
import org.fxmisc.richtext.StyleClassedTextArea;
43
44
/**
45
 * TestDefinitionPane application for debugging and head-banging.
46
 */
47
public abstract class TestHarness extends Application {
48
49
  private static Application app;
50
  private Scene scene;
51
52
  /**
53
   * Application entry point.
54
   *
55
   * @param stage The primary application stage.
56
   *
57
   * @throws Exception Could not read configuration file.
58
   */
59
  @Override
60
  public void start( final Stage stage ) throws Exception {
61
    initApplication();
62
    initScene();
63
    initStage( stage );
64
  }
65
  
66
  protected TreeView<String> createTreeView() throws IOException {
67
    return new YamlTreeAdapter( new YamlParser() ).adapt(
68
      asStream( "/com/scrivenvar/variables.yaml" ),
69
      get( "Pane.defintion.node.root.title" )
70
    );
71
  }
72
  
73
  protected DefinitionPane createDefinitionPane( TreeView<String> root ) {
74
    return new DefinitionPane( root );
75
  }
76
77
  private void initApplication() {
78
    app = this;
79
  }
80
81
  private void initScene() {
82
    final StyleClassedTextArea editor = new StyleClassedTextArea( false );
83
    final VirtualizedScrollPane<StyleClassedTextArea> scrollPane = new VirtualizedScrollPane<>( editor );
84
85
    final BorderPane borderPane = new BorderPane();
86
    borderPane.setPrefSize( 1024, 800 );
87
    borderPane.setCenter( scrollPane );
88
89
    setScene( new Scene( borderPane ) );
90
  }
91
92
  private void initStage( Stage stage ) {
93
    stage.setScene( getScene() );
94
  }
95
96
  private Scene getScene() {
97
    return this.scene;
98
  }
99
100
  private void setScene( Scene scene ) {
101
    this.scene = scene;
102
  }
103
104
  private static Application getApplication() {
105
    return app;
106
  }
107
108
  public static void showDocument( String uri ) {
109
    getApplication().getHostServices().showDocument( uri );
110
  }
111
112
  protected InputStream asStream( String resource ) {
113
    return getClass().getResourceAsStream( resource );
114
  }
115
}
1116
A src/main/java/com/scrivenvar/test/TestVariableNameProcessor.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.test;
29
30
import com.scrivenvar.definition.VariableTreeItem;
31
import java.util.Collection;
32
import java.util.HashMap;
33
import java.util.Map;
34
import static java.util.concurrent.ThreadLocalRandom.current;
35
import java.util.concurrent.TimeUnit;
36
import static java.util.concurrent.TimeUnit.DAYS;
37
import static java.util.concurrent.TimeUnit.HOURS;
38
import static java.util.concurrent.TimeUnit.MILLISECONDS;
39
import static java.util.concurrent.TimeUnit.MINUTES;
40
import static java.util.concurrent.TimeUnit.NANOSECONDS;
41
import static java.util.concurrent.TimeUnit.SECONDS;
42
import static javafx.application.Application.launch;
43
import javafx.scene.control.TreeItem;
44
import javafx.scene.control.TreeView;
45
import javafx.stage.Stage;
46
import org.ahocorasick.trie.*;
47
import org.ahocorasick.trie.Trie.TrieBuilder;
48
import static org.apache.commons.lang.RandomStringUtils.randomNumeric;
49
import org.apache.commons.lang.StringUtils;
50
51
/**
52
 * Tests substituting variable definitions with their values in a swath of text.
53
 *
54
 * @author White Magic Software, Ltd.
55
 */
56
public class TestVariableNameProcessor extends TestHarness {
57
58
  private final static int TEXT_SIZE = 1000000;
59
  private final static int MATCHES_DIVISOR = 1000;
60
61
  private final static StringBuilder SOURCE
62
    = new StringBuilder( randomNumeric( TEXT_SIZE ) );
63
64
  private final static boolean DEBUG = false;
65
66
  public TestVariableNameProcessor() {
67
  }
68
69
  @Override
70
  public void start( final Stage stage ) throws Exception {
71
    super.start( stage );
72
73
    final TreeView<String> treeView = createTreeView();
74
    final Map<String, String> definitions = new HashMap<>();
75
76
    populate( treeView.getRoot(), definitions );
77
    injectVariables( definitions );
78
79
    final String text = SOURCE.toString();
80
81
    show( text );
82
83
    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
89
    duration = System.nanoTime() - duration;
90
91
    show( result );
92
    System.out.println( elapsed( duration ) );
93
94
    System.exit( 0 );
95
  }
96
97
  private void show( final String s ) {
98
    if( DEBUG ) {
99
      System.out.printf( "%s\n\n", s );
100
    }
101
  }
102
103
  private String testBorAhoCorasick(
104
    final String text,
105
    final Map<String, String> definitions ) {
106
    // Create a buffer sufficiently large that re-allocations are minimized.
107
    final StringBuilder sb = new StringBuilder( text.length() << 1 );
108
109
    final TrieBuilder builder = Trie.builder();
110
    builder.onlyWholeWords();
111
    builder.removeOverlaps();
112
113
    final String[] keys = keys( definitions );
114
115
    for( final String key : keys ) {
116
      builder.addKeyword( key );
117
    }
118
119
    final Trie trie = builder.build();
120
    final Collection<Emit> emits = trie.parseText( text );
121
122
    int prevIndex = 0;
123
124
    for( final Emit emit : emits ) {
125
      final int matchIndex = emit.getStart();
126
127
      sb.append( text.substring( prevIndex, matchIndex ) );
128
      sb.append( definitions.get( emit.getKeyword() ) );
129
      prevIndex = emit.getEnd() + 1;
130
    }
131
132
    // Add the remainder of the string (contains no more matches).
133
    sb.append( text.substring( prevIndex ) );
134
135
    return sb.toString();
136
  }
137
138
  private String testStringUtils(
139
    final String text, final Map<String, String> definitions ) {
140
    final String[] keys = keys( definitions );
141
    final String[] values = values( definitions );
142
143
    return StringUtils.replaceEach( text, keys, values );
144
  }
145
146
  private String[] keys( final Map<String, String> definitions ) {
147
    final int size = definitions.size();
148
    return definitions.keySet().toArray( new String[ size ] );
149
  }
150
151
  private String[] values( final Map<String, String> definitions ) {
152
    final int size = definitions.size();
153
    return definitions.values().toArray( new String[ size ] );
154
  }
155
156
  /**
157
   * Decomposes a period of time into days, hours, minutes, seconds,
158
   * milliseconds, and nanoseconds.
159
   *
160
   * @param duration Time in nanoseconds.
161
   *
162
   * @return A non-null, comma-separated string (without newline).
163
   */
164
  public String elapsed( long duration ) {
165
    final TimeUnit scale = NANOSECONDS;
166
167
    long days = scale.toDays( duration );
168
    duration -= DAYS.toMillis( days );
169
    long hours = scale.toHours( duration );
170
    duration -= HOURS.toMillis( hours );
171
    long minutes = scale.toMinutes( duration );
172
    duration -= MINUTES.toMillis( minutes );
173
    long seconds = scale.toSeconds( duration );
174
    duration -= SECONDS.toMillis( seconds );
175
    long millis = scale.toMillis( duration );
176
    duration -= MILLISECONDS.toMillis( seconds );
177
    long nanos = scale.toNanos( duration );
178
179
    return String.format(
180
      "%d days, %d hours, %d minutes, %d seconds, %d millis, %d nanos",
181
      days, hours, minutes, seconds, millis, nanos
182
    );
183
  }
184
185
  private void injectVariables( final Map<String, String> definitions ) {
186
    for( int i = (SOURCE.length() / MATCHES_DIVISOR) + 1; i > 0; i-- ) {
187
      final int r = current().nextInt( 1, SOURCE.length() );
188
      SOURCE.insert( r, randomKey( definitions ) );
189
    }
190
  }
191
192
  private String randomKey( final Map<String, String> map ) {
193
    final Object[] keys = map.keySet().toArray();
194
    final int r = current().nextInt( keys.length );
195
    return keys[ r ].toString();
196
  }
197
198
  private void populate( final TreeItem<String> parent, final Map<String, String> map ) {
199
    for( final TreeItem<String> child : parent.getChildren() ) {
200
      if( child.isLeaf() ) {
201
        final String key = asDefinition( ((VariableTreeItem<String>)child).toPath() );
202
        final String value = child.getValue();
203
204
        map.put( key, value );
205
      } else {
206
        populate( child, map );
207
      }
208
    }
209
  }
210
211
  private String asDefinition( final String key ) {
212
    return "$" + key + "$";
213
  }
214
215
  public static void main( String[] args ) {
216
    launch( args );
217
  }
218
}
1219
D src/main/java/com/scrivenvar/ui/AbstractPane.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.ui;
29
30
import com.scrivenvar.Services;
31
import com.scrivenvar.service.Options;
32
import java.util.prefs.Preferences;
33
import org.tbee.javafx.scene.layout.fxml.MigPane;
34
35
/**
36
 * Provides options to all subclasses.
37
 *
38
 * @author White Magic Software, Ltd.
39
 */
40
public abstract class AbstractPane extends MigPane {
41
42
  private final Options options = Services.load( Options.class );
43
44
  protected Options getOptions() {
45
    return this.options;
46
  }
47
  
48
  protected Preferences getState() {
49
    return getOptions().getState();
50
  }
51
}
521
D src/main/java/com/scrivenvar/ui/VariableTreeItem.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.ui;
29
30
import static com.scrivenvar.Constants.SEPARATOR;
31
import com.scrivenvar.decorators.YamlVariableDecorator;
32
import com.scrivenvar.decorators.VariableDecorator;
33
import static com.scrivenvar.editor.VariableNameInjector.DEFAULT_MAX_VAR_LENGTH;
34
import java.util.HashMap;
35
import java.util.Map;
36
import java.util.Stack;
37
import javafx.scene.control.TreeItem;
38
39
/**
40
 * Provides behaviour afforded to variable names and their corresponding value.
41
 *
42
 * @author White Magic Software, Ltd.
43
 * @param <T> The type of TreeItem (usually String).
44
 */
45
public class VariableTreeItem<T> extends TreeItem<T> {
46
47
  private final static int DEFAULT_MAP_SIZE = 1000;
48
  
49
  private final static VariableDecorator VARIABLE_DECORATOR =
50
    new YamlVariableDecorator();
51
52
  /**
53
   * Flattened tree.
54
   */
55
  private Map<String, String> map;
56
57
  /**
58
   * Constructs a new item with a default value.
59
   *
60
   * @param value Passed up to superclass.
61
   */
62
  public VariableTreeItem( final T value ) {
63
    super( value );
64
  }
65
66
  /**
67
   * Finds a leaf starting at the current node with text that matches the given
68
   * value.
69
   *
70
   * @param text The text to match against each leaf in the tree.
71
   *
72
   * @return The leaf that has a value starting with the given text.
73
   */
74
  public VariableTreeItem<T> findLeaf( final String text ) {
75
    final Stack<VariableTreeItem<T>> stack = new Stack<>();
76
    final VariableTreeItem<T> root = this;
77
78
    stack.push( root );
79
80
    boolean found = false;
81
    VariableTreeItem<T> node = null;
82
83
    while( !found && !stack.isEmpty() ) {
84
      node = stack.pop();
85
86
      if( node.valueStartsWith( text ) ) {
87
        found = true;
88
      } else {
89
        for( final TreeItem<T> child : node.getChildren() ) {
90
          stack.push( (VariableTreeItem<T>)child );
91
        }
92
93
        // No match found, yet.
94
        node = null;
95
      }
96
    }
97
98
    return (VariableTreeItem<T>)node;
99
  }
100
101
  /**
102
   * Returns true if this node is a leaf and its value starts with the given
103
   * text.
104
   *
105
   * @param s The text to compare against the node value.
106
   *
107
   * @return true Node is a leaf and its value starts with the given value.
108
   */
109
  private boolean valueStartsWith( final String s ) {
110
    return isLeaf() && getValue().toString().startsWith( s );
111
  }
112
113
  /**
114
   * Returns the path for this node, with nodes made distinct using the
115
   * separator character. This uses two loops: one for pushing nodes onto a
116
   * stack and one for popping them off to create the path in desired order.
117
   *
118
   * @return A non-null string, possibly empty.
119
   */
120
  public String toPath() {
121
    final Stack<TreeItem<T>> stack = new Stack<>();
122
    TreeItem<T> node = this;
123
124
    while( node.getParent() != null ) {
125
      stack.push( node );
126
      node = node.getParent();
127
    }
128
129
    final StringBuilder sb = new StringBuilder( DEFAULT_MAX_VAR_LENGTH );
130
131
    while( !stack.isEmpty() ) {
132
      node = stack.pop();
133
134
      if( !node.isLeaf() ) {
135
        sb.append( node.getValue() );
136
137
        // This will add a superfluous separator, but instead of peeking at
138
        // the stack all the time, the last separator will be removed outside
139
        // the loop (one operation executed once).
140
        sb.append( SEPARATOR );
141
      }
142
    }
143
144
    // Remove the trailing SEPARATOR.
145
    if( sb.length() > 0 ) {
146
      sb.setLength( sb.length() - 1 );
147
    }
148
149
    return sb.toString();
150
  }
151
152
  /**
153
   * Returns the hierarchy, flattened to key-value pairs.
154
   *
155
   * @return A map of this tree's key-value pairs.
156
   */
157
  public Map<String, String> getMap() {
158
    if( this.map == null ) {
159
      this.map = new HashMap<>( DEFAULT_MAP_SIZE );
160
      populate( this, this.map );
161
    }
162
163
    return this.map;
164
  }
165
166
  private void populate( final TreeItem<T> parent, final Map<String, String> map ) {
167
    for( final TreeItem<T> child : parent.getChildren() ) {
168
      if( child.isLeaf() ) {
169
        @SuppressWarnings( "unchecked" )
170
        final String key = toVariable( ((VariableTreeItem<String>)child).toPath() );
171
        final String value = child.getValue().toString();
172
173
        map.put( key, value );
174
      } else {
175
        populate( child, map );
176
      }
177
    }
178
  }
179
180
  /**
181
   * Converts the name of the key to a simple variable by enclosing it with
182
   * dollar symbols.
183
   *
184
   * @param key The key name to change to a variable.
185
   *
186
   * @return $key$
187
   */
188
  public String toVariable( final String key ) {
189
    return VARIABLE_DECORATOR.decorate( key );
190
  }
191
}
1921
A src/main/java/com/scrivenvar/util/Lists.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.util;
29
30
import java.util.List;
31
32
/**
33
 * Convenience class that provides a clearer API for obtaining list elements.
34
 *
35
 * @author White Magic Software, Ltd.
36
 */
37
public final class Lists {
38
39
  private Lists() {
40
  }
41
42
  /**
43
   * Returns the first item in the given list, or null if not found.
44
   *
45
   * @param <T> The generic list type.
46
   * @param list The list that may have a first item.
47
   *
48
   * @return null if the list is null or there is no first item.
49
   */
50
  public static <T> T getFirst( final List<T> list ) {
51
    return getFirst( list, null );
52
  }
53
54
  /**
55
   * Returns the last item in the given list, or null if not found.
56
   *
57
   * @param <T> The generic list type.
58
   * @param list The list that may have a last item.
59
   *
60
   * @return null if the list is null or there is no last item.
61
   */
62
  public static <T> T getLast( final List<T> list ) {
63
    return getLast( list, null );
64
  }
65
66
  /**
67
   * Returns the first item in the given list, or t if not found.
68
   *
69
   * @param <T> The generic list type.
70
   * @param list The list that may have a first item.
71
   * @param t The default return value.
72
   *
73
   * @return null if the list is null or there is no first item.
74
   */
75
  public static <T> T getFirst( final List<T> list, final T t ) {
76
    return isEmpty( list ) ? t : list.get( 0 );
77
  }
78
79
  /**
80
   * Returns the last item in the given list, or t if not found.
81
   *
82
   * @param <T> The generic list type.
83
   * @param list The list that may have a last item.
84
   * @param t The default return value.
85
   *
86
   * @return null if the list is null or there is no last item.
87
   */
88
  public static <T> T getLast( final List<T> list, final T t ) {
89
    return isEmpty( list ) ? t : list.get( list.size() - 1 );
90
  }
91
92
  /**
93
   * Returns true if the given list is null or empty.
94
   *
95
   * @param <T> The generic list type.
96
   * @param list The list that has a last item.
97
   *
98
   * @return true The list is empty.
99
   */
100
  public static <T> boolean isEmpty( final List<T> list ) {
101
    return list == null || list.isEmpty();
102
  }
103
}
1104
M src/main/java/com/scrivenvar/util/StageState.java
5050
  private boolean runLaterPending;
5151
52
  public StageState( Stage stage, Preferences state ) {
52
  public StageState( final Stage stage, final Preferences state ) {
5353
    this.stage = stage;
5454
    this.state = state;
...
6565
6666
  private void save() {
67
    Rectangle bounds = isNormalState() ? getStageBounds() : normalBounds;
67
    final Rectangle bounds = isNormalState() ? getStageBounds() : normalBounds;
68
    
6869
    if( bounds != null ) {
6970
      state.putDouble( "windowX", bounds.getX() );
7071
      state.putDouble( "windowY", bounds.getY() );
7172
      state.putDouble( "windowWidth", bounds.getWidth() );
7273
      state.putDouble( "windowHeight", bounds.getHeight() );
7374
    }
75
    
7476
    state.putBoolean( "windowMaximized", stage.isMaximized() );
7577
    state.putBoolean( "windowFullScreen", stage.isFullScreen() );
7678
  }
7779
7880
  private void restore() {
79
    double x = state.getDouble( "windowX", Double.NaN );
80
    double y = state.getDouble( "windowY", Double.NaN );
81
    double w = state.getDouble( "windowWidth", Double.NaN );
82
    double h = state.getDouble( "windowHeight", Double.NaN );
83
    boolean maximized = state.getBoolean( "windowMaximized", false );
84
    boolean fullScreen = state.getBoolean( "windowFullScreen", false );
81
    final double x = state.getDouble( "windowX", Double.NaN );
82
    final double y = state.getDouble( "windowY", Double.NaN );
83
    final double w = state.getDouble( "windowWidth", Double.NaN );
84
    final double h = state.getDouble( "windowHeight", Double.NaN );
85
    final boolean maximized = state.getBoolean( "windowMaximized", false );
86
    final boolean fullScreen = state.getBoolean( "windowFullScreen", false );
8587
8688
    if( !Double.isNaN( x ) && !Double.isNaN( y ) ) {
...
9799
      stage.setFullScreen( fullScreen );
98100
    }
101
    
99102
    if( maximized != stage.isMaximized() ) {
100103
      stage.setMaximized( maximized );
...
111114
      return;
112115
    }
116
    
113117
    runLaterPending = true;
114118
D src/main/java/com/scrivenvar/yaml/YamlParser.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.yaml;
29
30
import com.fasterxml.jackson.core.JsonGenerationException;
31
import com.fasterxml.jackson.core.ObjectCodec;
32
import com.fasterxml.jackson.core.io.IOContext;
33
import com.fasterxml.jackson.databind.JsonNode;
34
import com.fasterxml.jackson.databind.ObjectMapper;
35
import com.fasterxml.jackson.databind.node.ObjectNode;
36
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
37
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
38
import static com.scrivenvar.Constants.SEPARATOR;
39
import com.scrivenvar.decorators.VariableDecorator;
40
import com.scrivenvar.decorators.YamlVariableDecorator;
41
import java.io.IOException;
42
import java.io.InputStream;
43
import java.io.Writer;
44
import java.security.InvalidParameterException;
45
import java.text.MessageFormat;
46
import java.util.HashMap;
47
import java.util.Map;
48
import java.util.Map.Entry;
49
import java.util.regex.Matcher;
50
import java.util.regex.Pattern;
51
import org.yaml.snakeyaml.DumperOptions;
52
53
/**
54
 * <p>
55
 * This program loads a YAML document into memory, scans for variable
56
 * declarations, then substitutes any self-referential values back into the
57
 * document. Its output is the given YAML document without any variables.
58
 * Variables in the YAML document are denoted using a bracketed dollar symbol
59
 * syntax. For example: $field.name$. Some nomenclature to keep from going
60
 * squirrely, consider:
61
 * </p>
62
 *
63
 * <pre>
64
 *   root:
65
 *     node:
66
 *       name: $field.name$
67
 *   field:
68
 *     name: Alan Turing
69
 * </pre>
70
 *
71
 * The various components of the given YAML are called:
72
 *
73
 * <ul>
74
 * <li><code>$field.name$</code> - delimited reference</li>
75
 * <li><code>field.name</code> - reference</li>
76
 * <li><code>name</code> - YAML field</li>
77
 * <li><code>Alan Turing</code> - (dereferenced) field value</li>
78
 * </ul>
79
 *
80
 * @author White Magic Software, Ltd.
81
 */
82
public class YamlParser {
83
84
  private final static int GROUP_DELIMITED = 1;
85
  private final static int GROUP_REFERENCE = 2;
86
87
  private final static VariableDecorator VARIABLE_DECORATOR
88
    = new YamlVariableDecorator();
89
90
  /**
91
   * Compiled version of DEFAULT_REGEX.
92
   */
93
  private final static Pattern REGEX_PATTERN
94
    = Pattern.compile( YamlVariableDecorator.REGEX );
95
96
  /**
97
   * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values.
98
   */
99
  private final static char SEPARATOR_YAML = '/';
100
101
  /**
102
   * Start of the Universe (the YAML document node that contains all others).
103
   */
104
  private ObjectNode documentRoot;
105
106
  /**
107
   * Map of references to dereferenced field values.
108
   */
109
  private Map<String, String> references;
110
111
  public YamlParser() {
112
  }
113
114
  /**
115
   * Returns the given string with all the delimited references swapped with
116
   * their recursively resolved values.
117
   *
118
   * @param text The text to parse with zero or more delimited references to
119
   * replace.
120
   *
121
   * @return The substituted value.
122
   *
123
   * @throws InvalidParameterException The text has no associated value.
124
   */
125
  public String substitute( String text ) {
126
    final Matcher matcher = patternMatch( text );
127
    final Map<String, String> map = getReferences();
128
129
    while( matcher.find() ) {
130
      final String key = matcher.group( GROUP_DELIMITED );
131
      final String value = map.get( key );
132
133
      if( value == null ) {
134
        missing( text );
135
      } else {
136
        text = text.replace( key, value );
137
      }
138
    }
139
140
    return text;
141
  }
142
143
  /**
144
   * Returns all the strings with their values resolved in a flat hierarchy.
145
   * This copies all the keys and resolved values into a new map.
146
   *
147
   * @return The new map created with all values having been resolved,
148
   * recursively.
149
   *
150
   * @throws InvalidParameterException A key in the map has no associated value.
151
   */
152
  public Map<String, String> createResolvedMap() {
153
    final Map<String, String> map = new HashMap<>( 1024 );
154
155
    resolve( getDocumentRoot(), "", map );
156
157
    return map;
158
  }
159
160
  /**
161
   * Iterate over a given root node (at any level of the tree) and adapt each
162
   * leaf node.
163
   *
164
   * @param rootNode A JSON node (YAML node) to adapt.
165
   */
166
  private void resolve(
167
    final JsonNode rootNode, final String path, final Map<String, String> map ) {
168
169
    rootNode.fields().forEachRemaining(
170
      (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map )
171
    );
172
  }
173
174
  /**
175
   * Recursively adapt each rootNode to a corresponding rootItem.
176
   *
177
   * @param rootNode The node to adapt.
178
   */
179
  private void resolve(
180
    final Entry<String, JsonNode> rootNode, final String path, final Map<String, String> map ) {
181
    final JsonNode leafNode = rootNode.getValue();
182
    final String key = rootNode.getKey();
183
184
    if( leafNode.isValueNode() ) {
185
      final String value = rootNode.getValue().asText();
186
187
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
188
    }
189
190
    if( leafNode.isObject() ) {
191
      resolve( leafNode, path + key + SEPARATOR, map );
192
    }
193
  }
194
195
  /**
196
   * Reads the first document from the given stream of YAML data and returns a
197
   * corresponding object that represents the YAML hierarchy. The calling class
198
   * is responsible for closing the stream. Calling classes should use
199
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
200
   *
201
   * @param in The input stream containing YAML content.
202
   *
203
   * @return An object hierarchy to represent the content.
204
   *
205
   * @throws IOException Could not read the stream.
206
   */
207
  public JsonNode process( final InputStream in ) throws IOException {
208
209
    final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in );
210
    setDocumentRoot( root );
211
    process( root );
212
    return getDocumentRoot();
213
  }
214
215
  /**
216
   * Iterate over a given root node (at any level of the tree) and process each
217
   * leaf node.
218
   *
219
   * @param root A node to process.
220
   */
221
  private void process( final JsonNode root ) {
222
    root.fields().forEachRemaining( this::process );
223
  }
224
225
  /**
226
   * Process the given field, which is a named node. This is where the
227
   * application does the up-front work of mapping references to their fully
228
   * recursively dereferenced values.
229
   *
230
   * @param field The named node.
231
   */
232
  private void process( final Entry<String, JsonNode> field ) {
233
    final JsonNode node = field.getValue();
234
235
    if( node.isObject() ) {
236
      process( node );
237
    } else {
238
      final JsonNode fieldValue = field.getValue();
239
240
      // Only basic data types can be parsed into variable values. For
241
      // node structures, YAML has a built-in mechanism.
242
      if( fieldValue.isValueNode() ) {
243
        try {
244
          resolve( fieldValue.asText() );
245
        } catch( StackOverflowError e ) {
246
          throw new IllegalArgumentException(
247
            "Unresolvable: " + node.textValue() + " = " + fieldValue );
248
        }
249
      }
250
    }
251
  }
252
253
  /**
254
   * Inserts the delimited references and field values into the cache. This will
255
   * overwrite existing references.
256
   *
257
   * @param fieldValue YAML field containing zero or more delimited references.
258
   * If it contains a delimited reference, the parameter is modified with the
259
   * dereferenced value before it is returned.
260
   *
261
   * @return fieldValue without delimited references.
262
   */
263
  private String resolve( String fieldValue ) {
264
    final Matcher matcher = patternMatch( fieldValue );
265
266
    while( matcher.find() ) {
267
      final String delimited = matcher.group( GROUP_DELIMITED );
268
      final String reference = matcher.group( GROUP_REFERENCE );
269
      final String dereference = resolve( lookup( reference ) );
270
271
      fieldValue = fieldValue.replace( delimited, dereference );
272
273
      // This will perform some superfluous calls by overwriting existing
274
      // items in the delimited reference map.
275
      put( delimited, dereference );
276
    }
277
278
    return fieldValue;
279
  }
280
281
  /**
282
   * Inserts a key/value pair into the references map. The map retains
283
   * references and dereferenced values found in the YAML. If the reference
284
   * already exists, this will overwrite with a new value.
285
   *
286
   * @param delimited The variable name.
287
   * @param dereferenced The resolved value.
288
   */
289
  private void put( String delimited, String dereferenced ) {
290
    if( dereferenced.isEmpty() ) {
291
      missing( delimited );
292
    } else {
293
      getReferences().put( delimited, dereferenced );
294
    }
295
  }
296
297
  /**
298
   * Writes the modified YAML document to standard output.
299
   */
300
  private void writeDocument() throws IOException {
301
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
302
  }
303
304
  /**
305
   * Called when a delimited reference is dereferenced to an empty string. This
306
   * should produce a warning for the user.
307
   *
308
   * @param delimited Delimited reference with no derived value.
309
   */
310
  private void missing( final String delimited ) {
311
    throw new InvalidParameterException(
312
      MessageFormat.format( "Missing value for '{0}'.", delimited ) );
313
  }
314
315
  /**
316
   * Returns a REGEX_PATTERN matcher for the given text.
317
   *
318
   * @param text The text that contains zero or more instances of a
319
   * REGEX_PATTERN that can be found using the regular expression.
320
   */
321
  private Matcher patternMatch( String text ) {
322
    return getPattern().matcher( text );
323
  }
324
325
  /**
326
   * Finds the YAML value for a reference.
327
   *
328
   * @param reference References a value in the YAML document.
329
   *
330
   * @return The dereferenced value.
331
   */
332
  private String lookup( final String reference ) {
333
    return getDocumentRoot().at( asPath( reference ) ).asText();
334
  }
335
336
  /**
337
   * Converts a reference (not delimited) to a path that can be used to find a
338
   * value that should exist inside the YAML document.
339
   *
340
   * @param reference The reference to convert to a YAML document path.
341
   *
342
   * @return The reference with a leading slash and its separator characters
343
   * converted to slashes.
344
   */
345
  private String asPath( final String reference ) {
346
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML );
347
  }
348
349
  /**
350
   * Sets the parent node for the entire YAML document tree.
351
   *
352
   * @param documentRoot The parent node.
353
   */
354
  private void setDocumentRoot( ObjectNode documentRoot ) {
355
    this.documentRoot = documentRoot;
356
  }
357
358
  /**
359
   * Returns the parent node for the entire YAML document tree.
360
   *
361
   * @return The parent node.
362
   */
363
  private ObjectNode getDocumentRoot() {
364
    return this.documentRoot;
365
  }
366
367
  /**
368
   * Returns the compiled regular expression REGEX_PATTERN used to match
369
   * delimited references.
370
   *
371
   * @return A compiled regex for use with the Matcher.
372
   */
373
  private Pattern getPattern() {
374
    return REGEX_PATTERN;
375
  }
376
377
  /**
378
   * Returns the list of references mapped to dereferenced values.
379
   *
380
   * @return
381
   */
382
  private Map<String, String> getReferences() {
383
    if( this.references == null ) {
384
      this.references = createReferences();
385
    }
386
387
    return this.references;
388
  }
389
390
  /**
391
   * Subclasses can override this method to insert their own map.
392
   *
393
   * @return An empty HashMap, never null.
394
   */
395
  protected Map<String, String> createReferences() {
396
    return new HashMap<>();
397
  }
398
399
  private class ResolverYAMLFactory extends YAMLFactory {
400
401
    @Override
402
    protected YAMLGenerator _createGenerator(
403
      final Writer out, final IOContext ctxt ) throws IOException {
404
405
      return new ResolverYAMLGenerator(
406
        ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec,
407
        out, _version );
408
    }
409
  }
410
411
  private class ResolverYAMLGenerator extends YAMLGenerator {
412
413
    public ResolverYAMLGenerator(
414
      final IOContext ctxt,
415
      final int jsonFeatures,
416
      final int yamlFeatures,
417
      final ObjectCodec codec,
418
      final Writer out,
419
      final DumperOptions.Version version ) throws IOException {
420
421
      super( ctxt, jsonFeatures, yamlFeatures, codec, out, version );
422
    }
423
424
    @Override
425
    public void writeString( final String text )
426
      throws IOException, JsonGenerationException {
427
      super.writeString( substitute( text ) );
428
    }
429
  }
430
431
  private YAMLFactory getYAMLFactory() {
432
    return new ResolverYAMLFactory();
433
  }
434
435
  private ObjectMapper getObjectMapper() {
436
    return new ObjectMapper( getYAMLFactory() );
437
  }
438
439
  /**
440
   * Returns the character used to separate YAML paths within delimited
441
   * references. This will return only the first character of the command line
442
   * parameter, if the default is overridden.
443
   *
444
   * @return A period by default.
445
   */
446
  private char getDelimitedSeparator() {
447
    return SEPARATOR.charAt( 0 );
448
  }
449
}
4501
D src/main/java/com/scrivenvar/yaml/YamlTreeAdapter.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.yaml;
29
30
import com.fasterxml.jackson.databind.JsonNode;
31
import com.scrivenvar.ui.VariableTreeItem;
32
import java.io.IOException;
33
import java.io.InputStream;
34
import java.util.Map.Entry;
35
import javafx.scene.control.TreeItem;
36
import javafx.scene.control.TreeView;
37
38
/**
39
 * Transforms a JsonNode hierarchy into a tree that can be displayed in a user
40
 * interface.
41
 *
42
 * @author White Magic Software, Ltd.
43
 */
44
public class YamlTreeAdapter {
45
46
  private YamlParser yamlParser;
47
48
  public YamlTreeAdapter( final YamlParser parser ) {
49
    setYamlParser( parser );
50
  }
51
52
  /**
53
   * Converts a YAML document to a TreeView based on the document keys. Only the
54
   * first document in the stream is adapted. This does not close the stream.
55
   *
56
   * @param in Contains a YAML document.
57
   * @param name Name of the root TreeItem.
58
   *
59
   * @return A TreeView populated with all the keys in the YAML document.
60
   *
61
   * @throws IOException Could not read from the stream.
62
   */
63
  public TreeView<String> adapt( final InputStream in, final String name )
64
    throws IOException {
65
66
    final JsonNode rootNode = getYamlParser().process( in );
67
    final TreeItem<String> rootItem = createTreeItem( name );
68
69
    rootItem.setExpanded( true );
70
    adapt( rootNode, rootItem );
71
    return new TreeView<>( rootItem );
72
  }
73
74
  /**
75
   * Iterate over a given root node (at any level of the tree) and adapt each
76
   * leaf node.
77
   *
78
   * @param rootNode A JSON node (YAML node) to adapt.
79
   * @param rootItem The tree item to use as the root when processing the node.
80
   */
81
  private void adapt(
82
    final JsonNode rootNode, final TreeItem<String> rootItem ) {
83
84
    rootNode.fields().forEachRemaining(
85
      (Entry<String, JsonNode> leaf) -> adapt( leaf, rootItem )
86
    );
87
  }
88
89
  /**
90
   * Recursively adapt each rootNode to a corresponding rootItem.
91
   *
92
   * @param rootNode The node to adapt.
93
   * @param rootItem The item to adapt using the node's key.
94
   */
95
  private void adapt(
96
    final Entry<String, JsonNode> rootNode, final TreeItem<String> rootItem ) {
97
98
    final JsonNode leafNode = rootNode.getValue();
99
    final String key = rootNode.getKey();
100
    final TreeItem<String> leaf = createTreeItem( key );
101
102
    if( leafNode.isValueNode() ) {
103
      leaf.getChildren().add( createTreeItem( rootNode.getValue().asText() ) );
104
    }
105
106
    rootItem.getChildren().add( leaf );
107
108
    if( leafNode.isObject() ) {
109
      adapt( leafNode, leaf );
110
    }
111
  }
112
113
  /**
114
   * Creates a new tree item that can be added to the tree view.
115
   *
116
   * @param value The node's value.
117
   *
118
   * @return A new tree item node, never null.
119
   */
120
  private TreeItem<String> createTreeItem( final String value ) {
121
    return new VariableTreeItem<>( value );
122
  }
123
124
  private YamlParser getYamlParser() {
125
    return this.yamlParser;
126
  }
127
128
  private void setYamlParser( final YamlParser yamlParser ) {
129
    this.yamlParser = yamlParser;
130
  }
131
132
}
1331
M src/main/resources/com/scrivenvar/preview/webview.css
228228
229229
kbd {
230
    -moz-border-bottom-colors: none;
231
    -moz-border-left-colors: none;
232
    -moz-border-right-colors: none;
233
    -moz-border-top-colors: none;
234
    background-color: #DDDDDD;
235
    background-image: linear-gradient(#F1F1F1, #DDDDDD);
236
    background-repeat: repeat-x;
237
    border-color: #DDDDDD #CCCCCC #CCCCCC #DDDDDD;
238
    border-image: none;
239
    border-radius: 2px 2px 2px 2px;
240
    border-style: solid;
241
    border-width: 1px;
242
    font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
243
    line-height: 10px;
244
    padding: 1px 4px;
230
  -moz-border-bottom-colors: none;
231
  -moz-border-left-colors: none;
232
  -moz-border-right-colors: none;
233
  -moz-border-top-colors: none;
234
  background-color: #DDDDDD;
235
  background-image: linear-gradient(#F1F1F1, #DDDDDD);
236
  background-repeat: repeat-x;
237
  border-color: #DDDDDD #CCCCCC #CCCCCC #DDDDDD;
238
  border-image: none;
239
  border-radius: 2px 2px 2px 2px;
240
  border-style: solid;
241
  border-width: 1px;
242
  font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
243
  line-height: 10px;
244
  padding: 1px 4px;
245245
}
246246
...
302302
img {
303303
  max-width: 100%
304
}
305
306
/* CARET 
307
=============================================================================*/
308
309
#CARETPOSITION {
310
  border-right:1px solid #333;
311
  margin-right:-1px;
312
  animation: blink 1s linear infinite;
313
}
314
315
@keyframes blink {
316
  from {
317
    visibility:hidden;
318
  }
319
  50% {
320
    visibility:hidden;
321
  }
322
  to {
323
    visibility:visible;
324
  }
304325
}
305326
M src/main/resources/com/scrivenvar/settings.properties
11
# ########################################################################
22
#
3
# Application
4
#
5
# ########################################################################
6
7
application.title=scrivenvar
8
application.package=com/${application.title}
9
application.messages= com.${application.title}.messages
10
11
# ########################################################################
12
#
13
# Preferences
14
#
15
# ########################################################################
16
17
preferences.root=com.${application.title}
18
preferences.root.state=state
19
preferences.root.options=options
20
21
# ########################################################################
22
#
23
# File References
24
#
25
# ########################################################################
26
27
file.stylesheet.scene=${application.package}/scene.css
28
file.stylesheet.markdown=${application.package}/editor/Markdown.css
29
file.stylesheet.preview=webview.css
30
31
file.logo.16 =${application.package}/logo16.png
32
file.logo.32 =${application.package}/logo32.png
33
file.logo.128=${application.package}/logo128.png
34
file.logo.256=${application.package}/logo256.png
35
file.logo.512=${application.package}/logo512.png
36
37
# ########################################################################
38
#
39
# Caret token
40
#
41
# ########################################################################
42
caret.token.base=CARETPOSITION
43
caret.token.markdown=%${constant.caret.token.base}%
44
caret.token.xml=<![CDATA[${constant.caret.token.markdown}]]>
45
caret.token.html=<span id="${caret.token.base}"></span>
46
47
# ########################################################################
48
#
349
# Filename Extensions
450
#
551
# ########################################################################
52
53
# Comma-separated list of definition filename extensions.
54
file.ext.definition.json=*.json
55
file.ext.definition.toml=*.toml
56
file.ext.definition.yaml=*.yml,*.yaml
57
file.ext.definition.properties=*.properties,*.props
658
759
# Comma-separated list of filename extensions.
8
Dialog.file.choose.filter.ext.markdown=*.Rmd,*.md,*.markdown,*.mkdown,*.mdown,*.mkdn,*.mkd,*.mdwn,*.mdtxt,*.mdtext,*.text,*.txt
9
Dialog.file.choose.filter.ext.definition=*.yml,*.yaml,*.properties,*.props
10
Dialog.file.choose.filter.ext.xml=*.xml,*.Rxml
11
Dialog.file.choose.filter.ext.all=*.*
60
filter.file.ext.markdown=*.Rmd,*.md,*.markdown,*.mkdown,*.mdown,*.mkdn,*.mkd,*.mdwn,*.mdtxt,*.mdtext,*.text,*.txt
61
filter.file.ext.definition=${file.ext.definition.yaml}
62
filter.file.ext.xml=*.xml,*.Rxml
63
filter.file.ext.all=*.*
1264
1365
# ########################################################################