Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M CHANGES.md
11
# Change Log
22
3
## 0.9
4
5
- Associate variable name injector with all tabs.
6
37
## 0.8
48
M src/main/java/com/scrivenvar/FileEditorTab.java
186186
    return getEditorPane().getEditor().getCaretPosition();
187187
  }
188
188
  
189189
  /**
190190
   * Returns true if the given path exactly matches this tab's path.
...
398398
   * @return The editor pane, never null.
399399
   */
400
  protected EditorPane getEditorPane() {
400
  public EditorPane getEditorPane() {
401401
    if( this.editorPane == null ) {
402402
      this.editorPane = new MarkdownEditorPane();
M src/main/java/com/scrivenvar/FileEditorTabPane.java
510510
    // This will allow dynamic filters to be added and removed just by
511511
    // updating the properties file.
512
    list.add( createExtensionFilter( "definition" ) );
513512
    list.add( createExtensionFilter( "markdown" ) );
513
    list.add( createExtensionFilter( "definition" ) );
514514
    list.add( createExtensionFilter( "xml" ) );
515515
    list.add( createExtensionFilter( "all" ) );
M src/main/java/com/scrivenvar/MainWindow.java
3131
import static com.scrivenvar.Constants.PREFS_DEFINITION_SOURCE;
3232
import static com.scrivenvar.Constants.STYLESHEET_SCENE;
33
import static com.scrivenvar.Messages.get;
34
import com.scrivenvar.definition.DefinitionFactory;
35
import com.scrivenvar.definition.DefinitionPane;
36
import com.scrivenvar.definition.DefinitionSource;
37
import com.scrivenvar.definition.EmptyDefinitionSource;
38
import com.scrivenvar.editors.VariableNameInjector;
39
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
40
import com.scrivenvar.preview.HTMLPreviewPane;
41
import com.scrivenvar.processors.HTMLPreviewProcessor;
42
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
43
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
44
import com.scrivenvar.processors.MarkdownProcessor;
45
import com.scrivenvar.processors.Processor;
46
import com.scrivenvar.processors.VariableProcessor;
47
import com.scrivenvar.service.Options;
48
import com.scrivenvar.util.Action;
49
import com.scrivenvar.util.ActionUtils;
50
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
51
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
52
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
66
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
67
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
68
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
69
import java.net.MalformedURLException;
70
import java.nio.file.Path;
71
import java.util.Map;
72
import java.util.function.Function;
73
import java.util.prefs.Preferences;
74
import javafx.beans.binding.Bindings;
75
import javafx.beans.binding.BooleanBinding;
76
import javafx.beans.property.BooleanProperty;
77
import javafx.beans.property.SimpleBooleanProperty;
78
import javafx.beans.value.ObservableBooleanValue;
79
import javafx.beans.value.ObservableValue;
80
import javafx.collections.ListChangeListener.Change;
81
import javafx.collections.ObservableList;
82
import javafx.event.Event;
83
import javafx.scene.Node;
84
import javafx.scene.Scene;
85
import javafx.scene.control.Alert;
86
import javafx.scene.control.Alert.AlertType;
87
import javafx.scene.control.Menu;
88
import javafx.scene.control.MenuBar;
89
import javafx.scene.control.SplitPane;
90
import javafx.scene.control.Tab;
91
import javafx.scene.control.ToolBar;
92
import javafx.scene.control.TreeView;
93
import javafx.scene.image.Image;
94
import javafx.scene.image.ImageView;
95
import static javafx.scene.input.KeyCode.ESCAPE;
96
import javafx.scene.input.KeyEvent;
97
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
98
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
99
import javafx.scene.layout.BorderPane;
100
import javafx.scene.layout.VBox;
101
import javafx.stage.Window;
102
import javafx.stage.WindowEvent;
103
104
/**
105
 * Main window containing a tab pane in the center for file editors.
106
 *
107
 * @author Karl Tauber and White Magic Software, Ltd.
108
 */
109
public class MainWindow {
110
111
  private final Options options = Services.load( Options.class );
112
113
  private Scene scene;
114
  private MenuBar menuBar;
115
116
  private DefinitionPane definitionPane;
117
  private FileEditorTabPane fileEditorPane;
118
  private HTMLPreviewPane previewPane;
119
120
  private VariableNameInjector variableNameInjector;
121
  private DefinitionSource definitionSource;
122
123
  public MainWindow() {
124
    initLayout();
125
    initOpenDefinitionListener();
126
    initTabAddedListener();
127
    initTabChangedListener();
128
    initPreferences();
129
    initVariableNameInjector();
130
  }
131
132
  /**
133
   * Listen for file editor tab pane to receive an open definition source event.
134
   */
135
  private void initOpenDefinitionListener() {
136
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
137
      (ObservableValue<? extends Path> definitionFile,
138
        final Path oldPath, final Path newPath) -> {
139
        openDefinition( newPath );
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
    restoreDefinitionSource();
177
  }
178
179
  /**
180
   * Listen for new tab selection events.
181
   */
182
  private void initTabChangedListener() {
183
    final FileEditorTabPane editorPane = getFileEditorPane();
184
185
    // Update the preview pane changing tabs.
186
    editorPane.addTabSelectionListener(
187
      (ObservableValue<? extends Tab> tabPane,
188
        final Tab oldTab, final Tab newTab) -> {
189
190
        // If there was no old tab, then this is a first time load, which
191
        // can be ignored.
192
        if( oldTab != null ) {
193
          if( newTab == null ) {
194
            closeRemainingTab();
195
          } else {
196
            // Synchronize the preview with the edited text.
197
            refreshSelectedTab( (FileEditorTab)newTab );
198
          }
199
        }
200
      }
201
    );
202
  }
203
204
  /**
205
   * Initialize the variable name editor.
206
   */
207
  private void initVariableNameInjector() {
208
    setVariableNameInjector(
209
      new VariableNameInjector( getFileEditorPane(), getDefinitionPane() )
210
    );
211
  }
212
213
  private void initTextChangeListener( final FileEditorTab tab ) {
214
    tab.addTextChangeListener(
215
      (ObservableValue<? extends String> editor,
216
        final String oldValue, final String newValue) -> {
217
        refreshSelectedTab( tab );
218
      }
219
    );
220
  }
221
222
  private void initCaretParagraphListener( final FileEditorTab tab ) {
223
    tab.addCaretParagraphListener(
224
      (ObservableValue<? extends Integer> editor,
225
        final Integer oldValue, final Integer newValue) -> {
226
        refreshSelectedTab( tab );
227
      }
228
    );
229
  }
230
231
  /**
232
   * Called whenever the preview pane becomes out of sync with the file editor
233
   * tab. This can be called when the text changes, the caret paragraph changes,
234
   * or the file tab changes.
235
   *
236
   * @param tab The file editor tab that has been changed in some fashion.
237
   */
238
  private void refreshSelectedTab( final FileEditorTab tab ) {
239
    final HTMLPreviewPane preview = getPreviewPane();
240
    preview.setPath( tab.getPath() );
241
242
    final Processor<String> hpp = new HTMLPreviewProcessor( preview );
243
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
244
    final Processor<String> mp = new MarkdownProcessor( mcrp );
245
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
246
    final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
247
248
    vp.processChain( tab.getEditorText() );
249
  }
250
251
  /**
252
   * Returns the variable map of interpolated definitions.
253
   *
254
   * @return A map to help dereference variables.
255
   */
256
  private Map<String, String> getResolvedMap() {
257
    return getDefinitionSource().getResolvedMap();
258
  }
259
260
  /**
261
   * Returns the root node for the hierarchical definition source.
262
   *
263
   * @return Data to display in the definition pane.
264
   */
265
  private TreeView<String> getTreeView() {
266
    try {
267
      return getDefinitionSource().asTreeView();
268
    } catch( Exception e ) {
269
      alert( e );
270
    }
271
272
    return new TreeView<>();
273
  }
274
275
  private void openDefinition( final Path path ) {
276
    openDefinition( path.toString() );
277
  }
278
279
  private void openDefinition( final String path ) {
280
    try {
281
      final DefinitionSource ds = createDefinitionSource( path );
282
      setDefinitionSource( ds );
283
      storeDefinitionSource();
284
285
      getDefinitionPane().setRoot( ds.asTreeView() );
286
    } catch( Exception e ) {
287
      alert( e );
288
    }
289
  }
290
291
  private void restoreDefinitionSource() {
292
    final Preferences preferences = getPreferences();
293
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
294
295
    if( source != null ) {
296
      openDefinition( source );
297
    }
298
  }
299
300
  private void storeDefinitionSource() {
301
    final Preferences preferences = getPreferences();
302
    final DefinitionSource ds = getDefinitionSource();
303
304
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
305
  }
306
307
  /**
308
   * Called when the last open tab is closed. This clears out the preview pane
309
   * and the definition pane.
310
   */
311
  private void closeRemainingTab() {
312
    getPreviewPane().clear();
313
    getDefinitionPane().clear();
314
  }
315
316
  /**
317
   * Called when an exception occurs that warrants the user's attention.
318
   *
319
   * @param e The exception with a message that the user should know about.
320
   */
321
  private void alert( final Exception e ) {
322
    // TODO: Raise a notice.
323
  }
324
325
  //---- File actions -------------------------------------------------------
326
  private void fileNew() {
327
    getFileEditorPane().newEditor();
328
  }
329
330
  private void fileOpen() {
331
    getFileEditorPane().openFileDialog();
332
  }
333
334
  private void fileClose() {
335
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
336
  }
337
338
  private void fileCloseAll() {
339
    getFileEditorPane().closeAllEditors();
340
  }
341
342
  private void fileSave() {
343
    getFileEditorPane().saveEditor( getActiveFileEditor() );
344
  }
345
346
  private void fileSaveAll() {
347
    getFileEditorPane().saveAllEditors();
348
  }
349
350
  private void fileExit() {
351
    final Window window = getWindow();
352
    Event.fireEvent( window,
353
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
354
  }
355
356
  //---- Help actions -------------------------------------------------------
357
  private void helpAbout() {
358
    Alert alert = new Alert( AlertType.INFORMATION );
359
    alert.setTitle( get( "Dialog.about.title" ) );
360
    alert.setHeaderText( get( "Dialog.about.header" ) );
361
    alert.setContentText( get( "Dialog.about.content" ) );
362
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
363
    alert.initOwner( getWindow() );
364
365
    alert.showAndWait();
366
  }
367
368
  //---- Convenience accessors ----------------------------------------------
369
  private float getFloat( final String key, final float defaultValue ) {
370
    return getPreferences().getFloat( key, defaultValue );
371
  }
372
373
  private Preferences getPreferences() {
374
    return getOptions().getState();
375
  }
376
377
  private Window getWindow() {
378
    return getScene().getWindow();
379
  }
380
381
  private MarkdownEditorPane getActiveEditor() {
382
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
383
  }
384
385
  private FileEditorTab getActiveFileEditor() {
386
    return getFileEditorPane().getActiveFileEditor();
387
  }
388
389
  //---- Member accessors ---------------------------------------------------
390
  public Scene getScene() {
391
    return this.scene;
392
  }
393
394
  private void setScene( Scene scene ) {
395
    this.scene = scene;
396
  }
397
398
  private FileEditorTabPane getFileEditorPane() {
399
    if( this.fileEditorPane == null ) {
400
      this.fileEditorPane = createFileEditorPane();
401
    }
402
403
    return this.fileEditorPane;
404
  }
405
406
  private synchronized HTMLPreviewPane getPreviewPane() {
407
    if( this.previewPane == null ) {
408
      this.previewPane = createPreviewPane();
409
    }
410
411
    return this.previewPane;
412
  }
413
414
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
415
    this.definitionSource = definitionSource;
416
  }
417
418
  private synchronized DefinitionSource getDefinitionSource() {
419
    if( this.definitionSource == null ) {
420
      this.definitionSource = new EmptyDefinitionSource();
421
    }
422
423
    return this.definitionSource;
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;
33
import com.scrivenvar.definition.DefinitionFactory;
34
import com.scrivenvar.definition.DefinitionPane;
35
import com.scrivenvar.definition.DefinitionSource;
36
import com.scrivenvar.definition.EmptyDefinitionSource;
37
import com.scrivenvar.editors.VariableNameInjector;
38
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
39
import com.scrivenvar.preview.HTMLPreviewPane;
40
import com.scrivenvar.processors.HTMLPreviewProcessor;
41
import com.scrivenvar.processors.MarkdownCaretInsertionProcessor;
42
import com.scrivenvar.processors.MarkdownCaretReplacementProcessor;
43
import com.scrivenvar.processors.MarkdownProcessor;
44
import com.scrivenvar.processors.Processor;
45
import com.scrivenvar.processors.VariableProcessor;
46
import com.scrivenvar.service.Options;
47
import com.scrivenvar.util.Action;
48
import com.scrivenvar.util.ActionUtils;
49
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_DEFINITION;
50
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_EDITOR;
51
import static com.scrivenvar.util.StageState.K_PANE_SPLIT_PREVIEW;
52
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.BOLD;
53
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.CODE;
54
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_ALT;
55
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FILE_CODE_ALT;
56
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FLOPPY_ALT;
57
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.FOLDER_OPEN_ALT;
58
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.HEADER;
59
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.ITALIC;
60
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LINK;
61
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_OL;
62
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.LIST_UL;
63
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.PICTURE_ALT;
64
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.QUOTE_LEFT;
65
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.REPEAT;
66
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.STRIKETHROUGH;
67
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.UNDO;
68
import java.net.MalformedURLException;
69
import java.nio.file.Path;
70
import java.util.Map;
71
import java.util.function.Function;
72
import java.util.prefs.Preferences;
73
import javafx.beans.binding.Bindings;
74
import javafx.beans.binding.BooleanBinding;
75
import javafx.beans.property.BooleanProperty;
76
import javafx.beans.property.SimpleBooleanProperty;
77
import javafx.beans.value.ObservableBooleanValue;
78
import javafx.beans.value.ObservableValue;
79
import javafx.collections.ListChangeListener.Change;
80
import javafx.collections.ObservableList;
81
import javafx.event.Event;
82
import javafx.scene.Node;
83
import javafx.scene.Scene;
84
import javafx.scene.control.Alert;
85
import javafx.scene.control.Alert.AlertType;
86
import javafx.scene.control.Menu;
87
import javafx.scene.control.MenuBar;
88
import javafx.scene.control.SplitPane;
89
import javafx.scene.control.Tab;
90
import javafx.scene.control.ToolBar;
91
import javafx.scene.control.TreeView;
92
import javafx.scene.image.Image;
93
import javafx.scene.image.ImageView;
94
import static javafx.scene.input.KeyCode.ESCAPE;
95
import javafx.scene.input.KeyEvent;
96
import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED;
97
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
98
import javafx.scene.layout.BorderPane;
99
import javafx.scene.layout.VBox;
100
import javafx.stage.Window;
101
import javafx.stage.WindowEvent;
102
import static com.scrivenvar.Messages.get;
103
104
/**
105
 * Main window containing a tab pane in the center for file editors.
106
 *
107
 * @author Karl Tauber and White Magic Software, Ltd.
108
 */
109
public class MainWindow {
110
111
  private final Options options = Services.load( Options.class );
112
113
  private Scene scene;
114
  private MenuBar menuBar;
115
116
  private DefinitionPane definitionPane;
117
  private FileEditorTabPane fileEditorPane;
118
  private HTMLPreviewPane previewPane;
119
120
  private DefinitionSource definitionSource;
121
122
  public MainWindow() {
123
    initLayout();
124
    initOpenDefinitionListener();
125
    initTabAddedListener();
126
    initTabChangedListener();
127
    initPreferences();
128
  }
129
130
  /**
131
   * Listen for file editor tab pane to receive an open definition source event.
132
   */
133
  private void initOpenDefinitionListener() {
134
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
135
      (ObservableValue<? extends Path> definitionFile,
136
        final Path oldPath, final Path newPath) -> {
137
        openDefinition( newPath );
138
        refreshSelectedTab( getActiveFileEditor() );
139
      } );
140
  }
141
142
  /**
143
   * When tabs are added, hook the various change listeners onto the new tab so
144
   * that the preview pane refreshes as necessary.
145
   */
146
  private void initTabAddedListener() {
147
    final FileEditorTabPane editorPane = getFileEditorPane();
148
149
    // Make sure the text processor kicks off when new files are opened.
150
    final ObservableList<Tab> tabs = editorPane.getTabs();
151
152
    // Update the preview pane on tab changes.
153
    tabs.addListener(
154
      (final Change<? extends Tab> change) -> {
155
        while( change.next() ) {
156
          if( change.wasAdded() ) {
157
            // Multiple tabs can be added simultaneously.
158
            for( final Tab newTab : change.getAddedSubList() ) {
159
              final FileEditorTab tab = (FileEditorTab)newTab;
160
161
              initTextChangeListener( tab );
162
              initCaretParagraphListener( tab );
163
              initVariableNameInjector( tab );
164
            }
165
          }
166
        }
167
      }
168
    );
169
  }
170
171
  /**
172
   * Reloads the preferences from the previous load.
173
   */
174
  private void initPreferences() {
175
    getFileEditorPane().restorePreferences();
176
    restoreDefinitionSource();
177
  }
178
179
  /**
180
   * Listen for new tab selection events.
181
   */
182
  private void initTabChangedListener() {
183
    final FileEditorTabPane editorPane = getFileEditorPane();
184
185
    // Update the preview pane changing tabs.
186
    editorPane.addTabSelectionListener(
187
      (ObservableValue<? extends Tab> tabPane,
188
        final Tab oldTab, final Tab newTab) -> {
189
190
        // If there was no old tab, then this is a first time load, which
191
        // can be ignored.
192
        if( oldTab != null ) {
193
          if( newTab == null ) {
194
            closeRemainingTab();
195
          } else {
196
            // Synchronize the preview with the edited text.
197
            refreshSelectedTab( (FileEditorTab)newTab );
198
          }
199
        }
200
      }
201
    );
202
  }
203
204
  private void initTextChangeListener( final FileEditorTab tab ) {
205
    tab.addTextChangeListener(
206
      (ObservableValue<? extends String> editor,
207
        final String oldValue, final String newValue) -> {
208
        refreshSelectedTab( tab );
209
      }
210
    );
211
  }
212
213
  private void initCaretParagraphListener( final FileEditorTab tab ) {
214
    tab.addCaretParagraphListener(
215
      (ObservableValue<? extends Integer> editor,
216
        final Integer oldValue, final Integer newValue) -> {
217
        refreshSelectedTab( tab );
218
      }
219
    );
220
  }
221
222
  private void initVariableNameInjector( final FileEditorTab tab ) {
223
    VariableNameInjector vni = new VariableNameInjector( tab, getDefinitionPane() );
224
  }
225
226
  /**
227
   * Called whenever the preview pane becomes out of sync with the file editor
228
   * tab. This can be called when the text changes, the caret paragraph changes,
229
   * or the file tab changes.
230
   *
231
   * @param tab The file editor tab that has been changed in some fashion.
232
   */
233
  private void refreshSelectedTab( final FileEditorTab tab ) {
234
    final HTMLPreviewPane preview = getPreviewPane();
235
    preview.setPath( tab.getPath() );
236
237
    final Processor<String> hpp = new HTMLPreviewProcessor( preview );
238
    final Processor<String> mcrp = new MarkdownCaretReplacementProcessor( hpp );
239
    final Processor<String> mp = new MarkdownProcessor( mcrp );
240
    final Processor<String> mcip = new MarkdownCaretInsertionProcessor( mp, tab.getCaretPosition() );
241
    final Processor<String> vp = new VariableProcessor( mcip, getResolvedMap() );
242
243
    vp.processChain( tab.getEditorText() );
244
  }
245
246
  /**
247
   * Returns the variable map of interpolated definitions.
248
   *
249
   * @return A map to help dereference variables.
250
   */
251
  private Map<String, String> getResolvedMap() {
252
    return getDefinitionSource().getResolvedMap();
253
  }
254
255
  /**
256
   * Returns the root node for the hierarchical definition source.
257
   *
258
   * @return Data to display in the definition pane.
259
   */
260
  private TreeView<String> getTreeView() {
261
    try {
262
      return getDefinitionSource().asTreeView();
263
    } catch( Exception e ) {
264
      alert( e );
265
    }
266
267
    return new TreeView<>();
268
  }
269
270
  private void openDefinition( final Path path ) {
271
    openDefinition( path.toString() );
272
  }
273
274
  private void openDefinition( final String path ) {
275
    try {
276
      final DefinitionSource ds = createDefinitionSource( path );
277
      setDefinitionSource( ds );
278
      storeDefinitionSource();
279
280
      getDefinitionPane().setRoot( ds.asTreeView() );
281
    } catch( Exception e ) {
282
      alert( e );
283
    }
284
  }
285
286
  private void restoreDefinitionSource() {
287
    final Preferences preferences = getPreferences();
288
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
289
290
    if( source != null ) {
291
      openDefinition( source );
292
    }
293
  }
294
295
  private void storeDefinitionSource() {
296
    final Preferences preferences = getPreferences();
297
    final DefinitionSource ds = getDefinitionSource();
298
299
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
300
  }
301
302
  /**
303
   * Called when the last open tab is closed. This clears out the preview pane
304
   * and the definition pane.
305
   */
306
  private void closeRemainingTab() {
307
    getPreviewPane().clear();
308
    getDefinitionPane().clear();
309
  }
310
311
  /**
312
   * Called when an exception occurs that warrants the user's attention.
313
   *
314
   * @param e The exception with a message that the user should know about.
315
   */
316
  private void alert( final Exception e ) {
317
    // TODO: Raise a notice.
318
  }
319
320
  //---- File actions -------------------------------------------------------
321
  private void fileNew() {
322
    getFileEditorPane().newEditor();
323
  }
324
325
  private void fileOpen() {
326
    getFileEditorPane().openFileDialog();
327
  }
328
329
  private void fileClose() {
330
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
331
  }
332
333
  private void fileCloseAll() {
334
    getFileEditorPane().closeAllEditors();
335
  }
336
337
  private void fileSave() {
338
    getFileEditorPane().saveEditor( getActiveFileEditor() );
339
  }
340
341
  private void fileSaveAll() {
342
    getFileEditorPane().saveAllEditors();
343
  }
344
345
  private void fileExit() {
346
    final Window window = getWindow();
347
    Event.fireEvent( window,
348
      new WindowEvent( window, WindowEvent.WINDOW_CLOSE_REQUEST ) );
349
  }
350
351
  //---- Help actions -------------------------------------------------------
352
  private void helpAbout() {
353
    Alert alert = new Alert( AlertType.INFORMATION );
354
    alert.setTitle( get( "Dialog.about.title" ) );
355
    alert.setHeaderText( get( "Dialog.about.header" ) );
356
    alert.setContentText( get( "Dialog.about.content" ) );
357
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
358
    alert.initOwner( getWindow() );
359
360
    alert.showAndWait();
361
  }
362
363
  //---- Convenience accessors ----------------------------------------------
364
  private float getFloat( final String key, final float defaultValue ) {
365
    return getPreferences().getFloat( key, defaultValue );
366
  }
367
368
  private Preferences getPreferences() {
369
    return getOptions().getState();
370
  }
371
372
  private Window getWindow() {
373
    return getScene().getWindow();
374
  }
375
376
  private MarkdownEditorPane getActiveEditor() {
377
    return (MarkdownEditorPane)(getActiveFileEditor().getEditorPane());
378
  }
379
380
  private FileEditorTab getActiveFileEditor() {
381
    return getFileEditorPane().getActiveFileEditor();
382
  }
383
384
  //---- Member accessors ---------------------------------------------------
385
  public Scene getScene() {
386
    return this.scene;
387
  }
388
389
  private void setScene( Scene scene ) {
390
    this.scene = scene;
391
  }
392
393
  private FileEditorTabPane getFileEditorPane() {
394
    if( this.fileEditorPane == null ) {
395
      this.fileEditorPane = createFileEditorPane();
396
    }
397
398
    return this.fileEditorPane;
399
  }
400
401
  private synchronized HTMLPreviewPane getPreviewPane() {
402
    if( this.previewPane == null ) {
403
      this.previewPane = createPreviewPane();
404
    }
405
406
    return this.previewPane;
407
  }
408
409
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
410
    this.definitionSource = definitionSource;
411
  }
412
413
  private synchronized DefinitionSource getDefinitionSource() {
414
    if( this.definitionSource == null ) {
415
      this.definitionSource = new EmptyDefinitionSource();
416
    }
417
418
    return this.definitionSource;
419
  }
420
421
  private DefinitionPane getDefinitionPane() {
422
    if( this.definitionPane == null ) {
423
      this.definitionPane = createDefinitionPane();
424
    }
425
426
    return this.definitionPane;
440427
  }
441428
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
2828
package com.scrivenvar.editors;
2929
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
66
/**
67
 * Provides the logic for injecting variable names within the editor.
68
 *
69
 * @author White Magic Software, Ltd.
70
 */
71
public class VariableNameInjector {
72
73
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
74
75
  private static final int NO_DIFFERENCE = -1;
76
77
  private final Settings settings = Services.load( Settings.class );
78
79
  /**
80
   * Used to capture keyboard events once the user presses @.
81
   */
82
  private InputMap<InputEvent> keyboardMap;
83
84
  private FileEditorTabPane fileEditorPane;
85
  private DefinitionPane definitionPane;
86
87
  /**
88
   * Position of the variable in the text when in variable mode (0 by default).
89
   */
90
  private int initialCaretPosition;
91
92
  public VariableNameInjector(
93
    final FileEditorTabPane editorPane,
94
    final DefinitionPane definitionPane ) {
95
    setFileEditorPane( editorPane );
96
    setDefinitionPane( definitionPane );
97
98
    initKeyboardEventListeners();
99
  }
100
101
  /**
102
   * Traps keys for performing various short-cut tasks, such as @-mode variable
103
   * insertion and control+space for variable autocomplete.
104
   *
105
   * @ key is pressed, a new keyboard map is inserted in place of the current
106
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
107
   *
108
   * @see createKeyboardMap()
109
   */
110
  private void initKeyboardEventListeners() {
111
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
112
113
    // @ key in Linux?
114
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
115
    // @ key in Windows.
116
    addEventListener( keyPressed( AT ), this::vMode );
117
  }
118
119
  /**
120
   * The @ symbol is a short-cut to inserting a YAML variable reference.
121
   *
122
   * @param e Superfluous information about the key that was pressed.
123
   */
124
  private void vMode( KeyEvent e ) {
125
    setInitialCaretPosition();
126
    vModeStart();
127
    vModeAutocomplete();
128
  }
129
130
  /**
131
   * Receives key presses until the user completes the variable selection. This
132
   * allows the arrow keys to be used for selecting variables.
133
   *
134
   * @param e The key that was pressed.
135
   */
136
  private void vModeKeyPressed( KeyEvent e ) {
137
    final KeyCode keyCode = e.getCode();
138
139
    switch( keyCode ) {
140
      case BACK_SPACE:
141
        // Don't decorate the variable upon exiting vMode.
142
        vModeBackspace();
143
        break;
144
145
      case ESCAPE:
146
        // Don't decorate the variable upon exiting vMode.
147
        vModeStop();
148
        break;
149
150
      case ENTER:
151
      case PERIOD:
152
      case RIGHT:
153
      case END:
154
        // Stop at a leaf node, ENTER means accept.
155
        if( vModeConditionalComplete() && keyCode == ENTER ) {
156
          vModeStop();
157
158
          // Decorate the variable upon exiting vMode.
159
          decorateVariable();
160
        }
161
        break;
162
163
      case UP:
164
        cyclePathPrev();
165
        break;
166
167
      case DOWN:
168
        cyclePathNext();
169
        break;
170
171
      default:
172
        vModeFilterKeyPressed( e );
173
        break;
174
    }
175
176
    e.consume();
177
  }
178
179
  private void vModeBackspace() {
180
    deleteSelection();
181
182
    // Break out of variable mode by back spacing to the original position.
183
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
184
      vModeAutocomplete();
185
    } else {
186
      vModeStop();
187
    }
188
  }
189
190
  /**
191
   * Updates the text with the path selected (or typed) by the user.
192
   */
193
  private void vModeAutocomplete() {
194
    final TreeItem<String> node = getCurrentNode();
195
196
    if( !node.isLeaf() ) {
197
      final String word = getLastPathWord();
198
      final String label = node.getValue();
199
      final int delta = difference( label, word );
200
      final String remainder = delta == NO_DIFFERENCE
201
        ? label
202
        : label.substring( delta );
203
204
      final StyledTextArea textArea = getEditor();
205
      final int posBegan = getCurrentCaretPosition();
206
      final int posEnded = posBegan + remainder.length();
207
208
      textArea.replaceSelection( remainder );
209
210
      if( posEnded - posBegan > 0 ) {
211
        textArea.selectRange( posEnded, posBegan );
212
      }
213
214
      expand( node );
215
    }
216
  }
217
218
  /**
219
   * Only variable name keys can pass through the filter. This is called when
220
   * the user presses a key.
221
   *
222
   * @param e The key that was pressed.
223
   */
224
  private void vModeFilterKeyPressed( final KeyEvent e ) {
225
    if( isVariableNameKey( e ) ) {
226
      typed( e.getText() );
227
    }
228
  }
229
230
  /**
231
   * Performs an autocomplete depending on whether the user has finished typing
232
   * in a word. If there is a selected range, then this will complete the most
233
   * recent word and jump to the next child.
234
   *
235
   * @return true The auto-completed node was a terminal node.
236
   */
237
  private boolean vModeConditionalComplete() {
238
    acceptPath();
239
240
    final TreeItem<String> node = getCurrentNode();
241
    final boolean terminal = isTerminal( node );
242
243
    if( !terminal ) {
244
      typed( SEPARATOR );
245
    }
246
247
    return terminal;
248
  }
249
250
  /**
251
   * Pressing control+space will find a node that matches the current word and
252
   * substitute the YAML variable reference. This is called when the user is not
253
   * editing in vMode.
254
   *
255
   * @param e Ignored -- it can only be Ctrl+Space.
256
   */
257
  private void autocomplete( KeyEvent e ) {
258
    final String paragraph = getCaretParagraph();
259
    final int[] boundaries = getWordBoundaries( paragraph );
260
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
261
262
    final VariableTreeItem<String> leaf = findLeaf( word );
263
264
    if( leaf != null ) {
265
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
266
      decorateVariable();
267
      expand( leaf );
268
    }
269
  }
270
271
  /**
272
   * Called when autocomplete finishes on a valid leaf or when the user presses
273
   * Enter to finish manual autocomplete.
274
   */
275
  private void decorateVariable() {
276
    // A little bit of duplication...
277
    final String paragraph = getCaretParagraph();
278
    final int[] boundaries = getWordBoundaries( paragraph );
279
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
280
281
    final String newVariable = getVariableDecorator().decorate( old );
282
283
    final int posEnded = getCurrentCaretPosition();
284
    final int posBegan = posEnded - old.length();
285
286
    getEditor().replaceText( posBegan, posEnded, newVariable );
287
  }
288
289
  /**
290
   * Updates the text at the given position within the current paragraph.
291
   *
292
   * @param posBegan The starting index in the paragraph text to replace.
293
   * @param posEnded The ending index in the paragraph text to replace.
294
   * @param text Overwrite the paragraph substring with this text.
295
   */
296
  private void replaceText(
297
    final int posBegan, final int posEnded, final String text ) {
298
    final int p = getCurrentParagraph();
299
300
    getEditor().replaceText( p, posBegan, p, posEnded, text );
301
  }
302
303
  /**
304
   * Returns the caret's current paragraph position.
305
   *
306
   * @return A number greater than or equal to 0.
307
   */
308
  private int getCurrentParagraph() {
309
    return getEditor().getCurrentParagraph();
310
  }
311
312
  /**
313
   * Returns current word boundary indexes into the current paragraph, including
314
   * punctuation.
315
   *
316
   * @param p The paragraph wherein to hunt word boundaries.
317
   * @param offset The offset into the paragraph to begin scanning left and
318
   * right.
319
   *
320
   * @return The starting and ending index of the word closest to the caret.
321
   */
322
  private int[] getWordBoundaries( final String p, final int offset ) {
323
    // Remove dashes, but retain hyphens. Retain same number of characters
324
    // to preserve relative indexes.
325
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
326
327
    return getWordAt( paragraph, offset );
328
  }
329
330
  /**
331
   * Helper method to get the word boundaries for the current paragraph.
332
   *
333
   * @param paragraph
334
   *
335
   * @return
336
   */
337
  private int[] getWordBoundaries( final String paragraph ) {
338
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
339
  }
340
341
  /**
342
   * Given an arbitrary offset into a string, this returns the word at that
343
   * index. The inputs and outputs include:
344
   *
345
   * <ul>
346
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
347
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
348
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
349
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
350
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
351
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
352
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
353
   * </ul>
354
   *
355
   * @param p The string to scan for a word.
356
   * @param offset The offset within s to begin searching for the nearest word
357
   * boundary, must not be out of bounds of s.
358
   *
359
   * @return The word in s at the offset.
360
   *
361
   * @see getWordBegan( String, int )
362
   * @see getWordEnded( String, int )
363
   */
364
  private int[] getWordAt( final String p, final int offset ) {
365
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
366
  }
367
368
  /**
369
   * Returns the index into s where a word begins.
370
   *
371
   * @param s Never null.
372
   * @param offset Index into s to begin searching backwards for a word
373
   * boundary.
374
   *
375
   * @return The index where a word begins.
376
   */
377
  private int getWordBegan( final String s, int offset ) {
378
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
379
      offset--;
380
    }
381
382
    return offset;
383
  }
384
385
  /**
386
   * Returns the index into s where a word ends.
387
   *
388
   * @param s Never null.
389
   * @param offset Index into s to begin searching forwards for a word boundary.
390
   *
391
   * @return The index where a word ends.
392
   */
393
  private int getWordEnded( final String s, int offset ) {
394
    final int length = s.length();
395
396
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
397
      offset++;
398
    }
399
400
    return offset;
401
  }
402
403
  /**
404
   * Returns true if the given character can be reasonably expected to be part
405
   * of a word, including punctuation marks.
406
   *
407
   * @param c The character to compare.
408
   *
409
   * @return false The character is a space character.
410
   */
411
  private boolean isBoundary( final char c ) {
412
    return !isSpaceChar( c );
413
  }
414
415
  /**
416
   * Returns the text for the paragraph that contains the caret.
417
   *
418
   * @return A non-null string, possibly empty.
419
   */
420
  private String getCaretParagraph() {
421
    return getEditor().getText( getCurrentParagraph() );
422
  }
423
424
  /**
425
   * Returns true if the node has children that can be selected (i.e., any
426
   * non-leaves).
427
   *
428
   * @param <T> The type that the TreeItem contains.
429
   * @param node The node to test for terminality.
430
   *
431
   * @return true The node has one branch and its a leaf.
432
   */
433
  private <T> boolean isTerminal( final TreeItem<T> node ) {
434
    final ObservableList<TreeItem<T>> branches = node.getChildren();
435
436
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
437
  }
438
439
  /**
440
   * Inserts text that the user typed at the current caret position, then
441
   * performs an autocomplete for the variable name.
442
   *
443
   * @param text The text to insert, never null.
444
   */
445
  private void typed( final String text ) {
446
    getEditor().replaceSelection( text );
447
    vModeAutocomplete();
448
  }
449
450
  /**
451
   * Called when the user presses either End or Enter key.
452
   */
453
  private void acceptPath() {
454
    final IndexRange range = getSelectionRange();
455
456
    if( range != null ) {
457
      final int rangeEnd = range.getEnd();
458
      final StyledTextArea textArea = getEditor();
459
      textArea.deselect();
460
      textArea.moveTo( rangeEnd );
461
    }
462
  }
463
464
  /**
465
   * Replaces the entirety of the existing path (from the initial caret
466
   * position) with the given path.
467
   *
468
   * @param oldPath The path to replace.
469
   * @param newPath The replacement path.
470
   */
471
  private void replacePath( final String oldPath, final String newPath ) {
472
    final StyledTextArea textArea = getEditor();
473
    final int posBegan = getInitialCaretPosition();
474
    final int posEnded = posBegan + oldPath.length();
475
476
    textArea.deselect();
477
    textArea.replaceText( posBegan, posEnded, newPath );
478
  }
479
480
  /**
481
   * Called when the user presses the Backspace key.
482
   */
483
  private void deleteSelection() {
484
    final StyledTextArea textArea = getEditor();
485
    textArea.replaceSelection( "" );
486
    textArea.deletePreviousChar();
487
  }
488
489
  /**
490
   * Cycles the selected text through the nodes.
491
   *
492
   * @param direction true - next; false - previous
493
   */
494
  private void cycleSelection( final boolean direction ) {
495
    final TreeItem<String> node = getCurrentNode();
496
497
    // Find the sibling for the current selection and replace the current
498
    // selection with the sibling's value
499
    TreeItem< String> cycled = direction
500
      ? node.nextSibling()
501
      : node.previousSibling();
502
503
    // When cycling at the end (or beginning) of the list, jump to the first
504
    // (or last) sibling depending on the cycle direction.
505
    if( cycled == null ) {
506
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
507
    }
508
509
    final String path = getCurrentPath();
510
    final String cycledWord = cycled.getValue();
511
    final String word = getLastPathWord();
512
    final int index = path.indexOf( word );
513
    final String cycledPath = path.substring( 0, index ) + cycledWord;
514
515
    expand( cycled );
516
    replacePath( path, cycledPath );
517
  }
518
519
  /**
520
   * Cycles to the next sibling of the currently selected tree node.
521
   */
522
  private void cyclePathNext() {
523
    cycleSelection( true );
524
  }
525
526
  /**
527
   * Cycles to the previous sibling of the currently selected tree node.
528
   */
529
  private void cyclePathPrev() {
530
    cycleSelection( false );
531
  }
532
533
  /**
534
   * Returns the variable name (or as much as has been typed so far). Returns
535
   * all the characters from the initial caret column to the the first
536
   * whitespace character. This will return a path that contains zero or more
537
   * separators.
538
   *
539
   * @return A non-null string, possibly empty.
540
   */
541
  private String getCurrentPath() {
542
    final String s = extractTextChunk();
543
    final int length = s.length();
544
545
    int i = 0;
546
547
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
548
      i++;
549
    }
550
551
    return s.substring( 0, i );
552
  }
553
554
  private <T> ObservableList<TreeItem<T>> getSiblings(
555
    final TreeItem<T> item ) {
556
    final TreeItem<T> parent = item.getParent();
557
    return parent == null ? item.getChildren() : parent.getChildren();
558
  }
559
560
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
561
    return getFirst( getSiblings( item ), item );
562
  }
563
564
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
565
    return getLast( getSiblings( item ), item );
566
  }
567
568
  /**
569
   * Returns the caret position as an offset into the text.
570
   *
571
   * @return A value from 0 to the length of the text (minus one).
572
   */
573
  private int getCurrentCaretPosition() {
574
    return getEditor().getCaretPosition();
575
  }
576
577
  /**
578
   * Returns the caret position within the current paragraph.
579
   *
580
   * @return A value from 0 to the length of the current paragraph.
581
   */
582
  private int getCurrentCaretColumn() {
583
    return getEditor().getCaretColumn();
584
  }
585
586
  /**
587
   * Returns the last word from the path.
588
   *
589
   * @return The last token.
590
   */
591
  private String getLastPathWord() {
592
    String path = getCurrentPath();
593
594
    int i = path.indexOf( SEPARATOR );
595
596
    while( i > 0 ) {
597
      path = path.substring( i + 1 );
598
      i = path.indexOf( SEPARATOR );
599
    }
600
601
    return path;
602
  }
603
604
  /**
605
   * Returns text from the initial caret position until some arbitrarily long
606
   * number of characters. The number of characters extracted will be
607
   * getMaxVarLength, or fewer, depending on how many characters remain to be
608
   * extracted. The result from this method is trimmed to the first whitespace
609
   * character.
610
   *
611
   * @return A chunk of text that includes all the words representing a path,
612
   * and then some.
613
   */
614
  private String extractTextChunk() {
615
    final StyledTextArea textArea = getEditor();
616
    final int textBegan = getInitialCaretPosition();
617
    final int remaining = textArea.getLength() - textBegan;
618
    final int textEnded = min( remaining, getMaxVarLength() );
619
620
    return textArea.getText( textBegan, textEnded );
621
  }
622
623
  /**
624
   * Returns the node for the current path.
625
   */
626
  private TreeItem<String> getCurrentNode() {
627
    return findNode( getCurrentPath() );
628
  }
629
630
  /**
631
   * Finds the node that most closely matches the given path.
632
   *
633
   * @param path The path that represents a node.
634
   *
635
   * @return The node for the path, or the root node if the path could not be
636
   * found, but never null.
637
   */
638
  private TreeItem<String> findNode( final String path ) {
639
    return getDefinitionPane().findNode( path );
640
  }
641
642
  /**
643
   * Finds the first leaf having a value that starts with the given text.
644
   *
645
   * @param text The text to find in the definition tree.
646
   *
647
   * @return The leaf that starts with the given text, or null if not found.
648
   */
649
  private VariableTreeItem<String> findLeaf( final String text ) {
650
    return getDefinitionPane().findLeaf( text );
651
  }
652
653
  /**
654
   * Used to ignore typed keys in favour of trapping pressed keys.
655
   *
656
   * @param e The key that was typed.
657
   */
658
  private void vModeKeyTyped( KeyEvent e ) {
659
    e.consume();
660
  }
661
662
  /**
663
   * Used to lazily initialize the keyboard map.
664
   *
665
   * @return Mappings for keyTyped and keyPressed.
666
   */
667
  protected InputMap<InputEvent> createKeyboardMap() {
668
    return sequence(
669
      consume( keyTyped(), this::vModeKeyTyped ),
670
      consume( keyPressed(), this::vModeKeyPressed )
671
    );
672
  }
673
674
  private InputMap<InputEvent> getKeyboardMap() {
675
    if( this.keyboardMap == null ) {
676
      this.keyboardMap = createKeyboardMap();
677
    }
678
679
    return this.keyboardMap;
680
  }
681
682
  /**
683
   * Collapses the tree then expands and selects the given node.
684
   *
685
   * @param node The node to expand.
686
   */
687
  private void expand( final TreeItem<String> node ) {
688
    final DefinitionPane pane = getDefinitionPane();
689
    pane.collapse();
690
    pane.expand( node );
691
    pane.select( node );
692
  }
693
694
  /**
695
   * Returns true iff the key code the user typed can be used as part of a YAML
696
   * variable name.
697
   *
698
   * @param keyEvent Keyboard key press event information.
699
   *
700
   * @return true The key is a value that can be inserted into the text.
701
   */
702
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
703
    final KeyCode kc = keyEvent.getCode();
704
705
    return (kc.isLetterKey()
706
      || kc.isDigitKey()
707
      || (keyEvent.isShiftDown() && kc == MINUS))
708
      && !keyEvent.isControlDown();
709
  }
710
711
  /**
712
   * Starts to capture user input events.
713
   */
714
  private void vModeStart() {
715
    addEventListener( getKeyboardMap() );
716
  }
717
718
  /**
719
   * Restores capturing of user input events to the previous event listener.
720
   * Also asks the processing chain to modify the variable text into a
721
   * machine-readable variable based on the format required by the file type.
722
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
723
   * file (.Rmd, .Rxml) will use `r#xVAR`.
724
   */
725
  private void vModeStop() {
726
    removeEventListener( getKeyboardMap() );
727
  }
728
729
  private VariableDecorator getVariableDecorator() {
730
    return new YamlVariableDecorator();
731
  }
732
733
  /**
734
   * Returns the index where the two strings diverge.
735
   *
736
   * @param s1 The string that could be a substring of s2, null allowed.
737
   * @param s2 The string that could be a substring of s1, null allowed.
738
   *
739
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
740
   * where they differ.
741
   */
742
  @SuppressWarnings( "StringEquality" )
743
  private int difference( final CharSequence s1, final CharSequence s2 ) {
744
    if( s1 == s2 ) {
745
      return NO_DIFFERENCE;
746
    }
747
748
    if( s1 == null || s2 == null ) {
749
      return 0;
750
    }
751
752
    int i = 0;
753
    final int limit = min( s1.length(), s2.length() );
754
755
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
756
      i++;
757
    }
758
759
    // If one string was shorter than the other, that's where they differ.
760
    return i;
761
  }
762
763
  /**
764
   * Delegates to the file editor pane, and, ultimately, to its text area.
765
   */
766
  private <T extends Event, U extends T> void addEventListener(
767
    final EventPattern<? super T, ? extends U> event,
768
    final Consumer<? super U> consumer ) {
769
    getFileEditorPane().addEventListener( event, consumer );
770
  }
771
772
  /**
773
   * Delegates to the file editor pane, and, ultimately, to its text area.
774
   *
775
   * @param map The map of methods to events.
776
   */
777
  private void addEventListener( final InputMap<InputEvent> map ) {
778
    getFileEditorPane().addEventListener( map );
779
  }
780
781
  private void removeEventListener( final InputMap<InputEvent> map ) {
782
    getFileEditorPane().removeEventListener( map );
783
  }
784
785
  /**
786
   * Returns the position of the caret when variable mode editing was requested.
787
   *
788
   * @return The variable mode caret position.
789
   */
790
  private int getInitialCaretPosition() {
791
    return this.initialCaretPosition;
792
  }
793
794
  /**
795
   * Sets the position of the caret when variable mode editing was requested.
796
   * Stores the current position because only the text that comes afterwards is
797
   * a suitable variable reference.
798
   *
799
   * @return The variable mode caret position.
800
   */
801
  private void setInitialCaretPosition() {
802
    this.initialCaretPosition = getEditor().getCaretPosition();
803
  }
804
805
  private StyledTextArea getEditor() {
806
    return getFileEditorPane().getEditor();
807
  }
808
809
  public FileEditorTabPane getFileEditorPane() {
810
    return this.fileEditorPane;
811
  }
812
813
  private void setFileEditorPane( final FileEditorTabPane fileEditorPane ) {
814
    this.fileEditorPane = fileEditorPane;
30
import com.scrivenvar.FileEditorTab;
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
66
/**
67
 * Provides the logic for injecting variable names within the editor.
68
 *
69
 * @author White Magic Software, Ltd.
70
 */
71
public class VariableNameInjector {
72
73
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
74
75
  private static final int NO_DIFFERENCE = -1;
76
77
  private final Settings settings = Services.load( Settings.class );
78
79
  /**
80
   * Used to capture keyboard events once the user presses @.
81
   */
82
  private InputMap<InputEvent> keyboardMap;
83
84
  private FileEditorTab tab;
85
  private DefinitionPane definitionPane;
86
87
  /**
88
   * Position of the variable in the text when in variable mode (0 by default).
89
   */
90
  private int initialCaretPosition;
91
92
  public VariableNameInjector(
93
    final FileEditorTab tab,
94
    final DefinitionPane definitionPane ) {
95
    setFileEditorTab( tab );
96
    setDefinitionPane( definitionPane );
97
98
    initKeyboardEventListeners();
99
  }
100
101
  /**
102
   * Traps keys for performing various short-cut tasks, such as @-mode variable
103
   * insertion and control+space for variable autocomplete.
104
   *
105
   * @ key is pressed, a new keyboard map is inserted in place of the current
106
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
107
   *
108
   * @see createKeyboardMap()
109
   */
110
  private void initKeyboardEventListeners() {
111
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
112
113
    // @ key in Linux?
114
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
115
    // @ key in Windows.
116
    addEventListener( keyPressed( AT ), this::vMode );
117
  }
118
119
  /**
120
   * The @ symbol is a short-cut to inserting a YAML variable reference.
121
   *
122
   * @param e Superfluous information about the key that was pressed.
123
   */
124
  private void vMode( KeyEvent e ) {
125
    setInitialCaretPosition();
126
    vModeStart();
127
    vModeAutocomplete();
128
  }
129
130
  /**
131
   * Receives key presses until the user completes the variable selection. This
132
   * allows the arrow keys to be used for selecting variables.
133
   *
134
   * @param e The key that was pressed.
135
   */
136
  private void vModeKeyPressed( KeyEvent e ) {
137
    final KeyCode keyCode = e.getCode();
138
139
    switch( keyCode ) {
140
      case BACK_SPACE:
141
        // Don't decorate the variable upon exiting vMode.
142
        vModeBackspace();
143
        break;
144
145
      case ESCAPE:
146
        // Don't decorate the variable upon exiting vMode.
147
        vModeStop();
148
        break;
149
150
      case ENTER:
151
      case PERIOD:
152
      case RIGHT:
153
      case END:
154
        // Stop at a leaf node, ENTER means accept.
155
        if( vModeConditionalComplete() && keyCode == ENTER ) {
156
          vModeStop();
157
158
          // Decorate the variable upon exiting vMode.
159
          decorateVariable();
160
        }
161
        break;
162
163
      case UP:
164
        cyclePathPrev();
165
        break;
166
167
      case DOWN:
168
        cyclePathNext();
169
        break;
170
171
      default:
172
        vModeFilterKeyPressed( e );
173
        break;
174
    }
175
176
    e.consume();
177
  }
178
179
  private void vModeBackspace() {
180
    deleteSelection();
181
182
    // Break out of variable mode by back spacing to the original position.
183
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
184
      vModeAutocomplete();
185
    } else {
186
      vModeStop();
187
    }
188
  }
189
190
  /**
191
   * Updates the text with the path selected (or typed) by the user.
192
   */
193
  private void vModeAutocomplete() {
194
    final TreeItem<String> node = getCurrentNode();
195
196
    if( !node.isLeaf() ) {
197
      final String word = getLastPathWord();
198
      final String label = node.getValue();
199
      final int delta = difference( label, word );
200
      final String remainder = delta == NO_DIFFERENCE
201
        ? label
202
        : label.substring( delta );
203
204
      final StyledTextArea textArea = getEditor();
205
      final int posBegan = getCurrentCaretPosition();
206
      final int posEnded = posBegan + remainder.length();
207
208
      textArea.replaceSelection( remainder );
209
210
      if( posEnded - posBegan > 0 ) {
211
        textArea.selectRange( posEnded, posBegan );
212
      }
213
214
      expand( node );
215
    }
216
  }
217
218
  /**
219
   * Only variable name keys can pass through the filter. This is called when
220
   * the user presses a key.
221
   *
222
   * @param e The key that was pressed.
223
   */
224
  private void vModeFilterKeyPressed( final KeyEvent e ) {
225
    if( isVariableNameKey( e ) ) {
226
      typed( e.getText() );
227
    }
228
  }
229
230
  /**
231
   * Performs an autocomplete depending on whether the user has finished typing
232
   * in a word. If there is a selected range, then this will complete the most
233
   * recent word and jump to the next child.
234
   *
235
   * @return true The auto-completed node was a terminal node.
236
   */
237
  private boolean vModeConditionalComplete() {
238
    acceptPath();
239
240
    final TreeItem<String> node = getCurrentNode();
241
    final boolean terminal = isTerminal( node );
242
243
    if( !terminal ) {
244
      typed( SEPARATOR );
245
    }
246
247
    return terminal;
248
  }
249
250
  /**
251
   * Pressing control+space will find a node that matches the current word and
252
   * substitute the YAML variable reference. This is called when the user is not
253
   * editing in vMode.
254
   *
255
   * @param e Ignored -- it can only be Ctrl+Space.
256
   */
257
  private void autocomplete( final KeyEvent e ) {
258
    final String paragraph = getCaretParagraph();
259
    final int[] boundaries = getWordBoundaries( paragraph );
260
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
261
262
    final VariableTreeItem<String> leaf = findLeaf( word );
263
264
    if( leaf != null ) {
265
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
266
      decorateVariable();
267
      expand( leaf );
268
    }
269
  }
270
271
  /**
272
   * Called when autocomplete finishes on a valid leaf or when the user presses
273
   * Enter to finish manual autocomplete.
274
   */
275
  private void decorateVariable() {
276
    // A little bit of duplication...
277
    final String paragraph = getCaretParagraph();
278
    final int[] boundaries = getWordBoundaries( paragraph );
279
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
280
281
    final String newVariable = getVariableDecorator().decorate( old );
282
283
    final int posEnded = getCurrentCaretPosition();
284
    final int posBegan = posEnded - old.length();
285
286
    getEditor().replaceText( posBegan, posEnded, newVariable );
287
  }
288
289
  /**
290
   * Updates the text at the given position within the current paragraph.
291
   *
292
   * @param posBegan The starting index in the paragraph text to replace.
293
   * @param posEnded The ending index in the paragraph text to replace.
294
   * @param text Overwrite the paragraph substring with this text.
295
   */
296
  private void replaceText(
297
    final int posBegan, final int posEnded, final String text ) {
298
    final int p = getCurrentParagraph();
299
300
    getEditor().replaceText( p, posBegan, p, posEnded, text );
301
  }
302
303
  /**
304
   * Returns the caret's current paragraph position.
305
   *
306
   * @return A number greater than or equal to 0.
307
   */
308
  private int getCurrentParagraph() {
309
    return getEditor().getCurrentParagraph();
310
  }
311
312
  /**
313
   * Returns current word boundary indexes into the current paragraph, including
314
   * punctuation.
315
   *
316
   * @param p The paragraph wherein to hunt word boundaries.
317
   * @param offset The offset into the paragraph to begin scanning left and
318
   * right.
319
   *
320
   * @return The starting and ending index of the word closest to the caret.
321
   */
322
  private int[] getWordBoundaries( final String p, final int offset ) {
323
    // Remove dashes, but retain hyphens. Retain same number of characters
324
    // to preserve relative indexes.
325
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
326
327
    return getWordAt( paragraph, offset );
328
  }
329
330
  /**
331
   * Helper method to get the word boundaries for the current paragraph.
332
   *
333
   * @param paragraph
334
   *
335
   * @return
336
   */
337
  private int[] getWordBoundaries( final String paragraph ) {
338
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
339
  }
340
341
  /**
342
   * Given an arbitrary offset into a string, this returns the word at that
343
   * index. The inputs and outputs include:
344
   *
345
   * <ul>
346
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
347
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
348
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
349
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
350
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
351
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
352
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
353
   * </ul>
354
   *
355
   * @param p The string to scan for a word.
356
   * @param offset The offset within s to begin searching for the nearest word
357
   * boundary, must not be out of bounds of s.
358
   *
359
   * @return The word in s at the offset.
360
   *
361
   * @see getWordBegan( String, int )
362
   * @see getWordEnded( String, int )
363
   */
364
  private int[] getWordAt( final String p, final int offset ) {
365
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
366
  }
367
368
  /**
369
   * Returns the index into s where a word begins.
370
   *
371
   * @param s Never null.
372
   * @param offset Index into s to begin searching backwards for a word
373
   * boundary.
374
   *
375
   * @return The index where a word begins.
376
   */
377
  private int getWordBegan( final String s, int offset ) {
378
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
379
      offset--;
380
    }
381
382
    return offset;
383
  }
384
385
  /**
386
   * Returns the index into s where a word ends.
387
   *
388
   * @param s Never null.
389
   * @param offset Index into s to begin searching forwards for a word boundary.
390
   *
391
   * @return The index where a word ends.
392
   */
393
  private int getWordEnded( final String s, int offset ) {
394
    final int length = s.length();
395
396
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
397
      offset++;
398
    }
399
400
    return offset;
401
  }
402
403
  /**
404
   * Returns true if the given character can be reasonably expected to be part
405
   * of a word, including punctuation marks.
406
   *
407
   * @param c The character to compare.
408
   *
409
   * @return false The character is a space character.
410
   */
411
  private boolean isBoundary( final char c ) {
412
    return !isSpaceChar( c );
413
  }
414
415
  /**
416
   * Returns the text for the paragraph that contains the caret.
417
   *
418
   * @return A non-null string, possibly empty.
419
   */
420
  private String getCaretParagraph() {
421
    return getEditor().getText( getCurrentParagraph() );
422
  }
423
424
  /**
425
   * Returns true if the node has children that can be selected (i.e., any
426
   * non-leaves).
427
   *
428
   * @param <T> The type that the TreeItem contains.
429
   * @param node The node to test for terminality.
430
   *
431
   * @return true The node has one branch and its a leaf.
432
   */
433
  private <T> boolean isTerminal( final TreeItem<T> node ) {
434
    final ObservableList<TreeItem<T>> branches = node.getChildren();
435
436
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
437
  }
438
439
  /**
440
   * Inserts text that the user typed at the current caret position, then
441
   * performs an autocomplete for the variable name.
442
   *
443
   * @param text The text to insert, never null.
444
   */
445
  private void typed( final String text ) {
446
    getEditor().replaceSelection( text );
447
    vModeAutocomplete();
448
  }
449
450
  /**
451
   * Called when the user presses either End or Enter key.
452
   */
453
  private void acceptPath() {
454
    final IndexRange range = getSelectionRange();
455
456
    if( range != null ) {
457
      final int rangeEnd = range.getEnd();
458
      final StyledTextArea textArea = getEditor();
459
      textArea.deselect();
460
      textArea.moveTo( rangeEnd );
461
    }
462
  }
463
464
  /**
465
   * Replaces the entirety of the existing path (from the initial caret
466
   * position) with the given path.
467
   *
468
   * @param oldPath The path to replace.
469
   * @param newPath The replacement path.
470
   */
471
  private void replacePath( final String oldPath, final String newPath ) {
472
    final StyledTextArea textArea = getEditor();
473
    final int posBegan = getInitialCaretPosition();
474
    final int posEnded = posBegan + oldPath.length();
475
476
    textArea.deselect();
477
    textArea.replaceText( posBegan, posEnded, newPath );
478
  }
479
480
  /**
481
   * Called when the user presses the Backspace key.
482
   */
483
  private void deleteSelection() {
484
    final StyledTextArea textArea = getEditor();
485
    textArea.replaceSelection( "" );
486
    textArea.deletePreviousChar();
487
  }
488
489
  /**
490
   * Cycles the selected text through the nodes.
491
   *
492
   * @param direction true - next; false - previous
493
   */
494
  private void cycleSelection( final boolean direction ) {
495
    final TreeItem<String> node = getCurrentNode();
496
497
    // Find the sibling for the current selection and replace the current
498
    // selection with the sibling's value
499
    TreeItem< String> cycled = direction
500
      ? node.nextSibling()
501
      : node.previousSibling();
502
503
    // When cycling at the end (or beginning) of the list, jump to the first
504
    // (or last) sibling depending on the cycle direction.
505
    if( cycled == null ) {
506
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
507
    }
508
509
    final String path = getCurrentPath();
510
    final String cycledWord = cycled.getValue();
511
    final String word = getLastPathWord();
512
    final int index = path.indexOf( word );
513
    final String cycledPath = path.substring( 0, index ) + cycledWord;
514
515
    expand( cycled );
516
    replacePath( path, cycledPath );
517
  }
518
519
  /**
520
   * Cycles to the next sibling of the currently selected tree node.
521
   */
522
  private void cyclePathNext() {
523
    cycleSelection( true );
524
  }
525
526
  /**
527
   * Cycles to the previous sibling of the currently selected tree node.
528
   */
529
  private void cyclePathPrev() {
530
    cycleSelection( false );
531
  }
532
533
  /**
534
   * Returns the variable name (or as much as has been typed so far). Returns
535
   * all the characters from the initial caret column to the the first
536
   * whitespace character. This will return a path that contains zero or more
537
   * separators.
538
   *
539
   * @return A non-null string, possibly empty.
540
   */
541
  private String getCurrentPath() {
542
    final String s = extractTextChunk();
543
    final int length = s.length();
544
545
    int i = 0;
546
547
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
548
      i++;
549
    }
550
551
    return s.substring( 0, i );
552
  }
553
554
  private <T> ObservableList<TreeItem<T>> getSiblings(
555
    final TreeItem<T> item ) {
556
    final TreeItem<T> parent = item.getParent();
557
    return parent == null ? item.getChildren() : parent.getChildren();
558
  }
559
560
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
561
    return getFirst( getSiblings( item ), item );
562
  }
563
564
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
565
    return getLast( getSiblings( item ), item );
566
  }
567
568
  /**
569
   * Returns the caret position as an offset into the text.
570
   *
571
   * @return A value from 0 to the length of the text (minus one).
572
   */
573
  private int getCurrentCaretPosition() {
574
    return getEditor().getCaretPosition();
575
  }
576
577
  /**
578
   * Returns the caret position within the current paragraph.
579
   *
580
   * @return A value from 0 to the length of the current paragraph.
581
   */
582
  private int getCurrentCaretColumn() {
583
    return getEditor().getCaretColumn();
584
  }
585
586
  /**
587
   * Returns the last word from the path.
588
   *
589
   * @return The last token.
590
   */
591
  private String getLastPathWord() {
592
    String path = getCurrentPath();
593
594
    int i = path.indexOf( SEPARATOR );
595
596
    while( i > 0 ) {
597
      path = path.substring( i + 1 );
598
      i = path.indexOf( SEPARATOR );
599
    }
600
601
    return path;
602
  }
603
604
  /**
605
   * Returns text from the initial caret position until some arbitrarily long
606
   * number of characters. The number of characters extracted will be
607
   * getMaxVarLength, or fewer, depending on how many characters remain to be
608
   * extracted. The result from this method is trimmed to the first whitespace
609
   * character.
610
   *
611
   * @return A chunk of text that includes all the words representing a path,
612
   * and then some.
613
   */
614
  private String extractTextChunk() {
615
    final StyledTextArea textArea = getEditor();
616
    final int textBegan = getInitialCaretPosition();
617
    final int remaining = textArea.getLength() - textBegan;
618
    final int textEnded = min( remaining, getMaxVarLength() );
619
620
    return textArea.getText( textBegan, textEnded );
621
  }
622
623
  /**
624
   * Returns the node for the current path.
625
   */
626
  private TreeItem<String> getCurrentNode() {
627
    return findNode( getCurrentPath() );
628
  }
629
630
  /**
631
   * Finds the node that most closely matches the given path.
632
   *
633
   * @param path The path that represents a node.
634
   *
635
   * @return The node for the path, or the root node if the path could not be
636
   * found, but never null.
637
   */
638
  private TreeItem<String> findNode( final String path ) {
639
    return getDefinitionPane().findNode( path );
640
  }
641
642
  /**
643
   * Finds the first leaf having a value that starts with the given text.
644
   *
645
   * @param text The text to find in the definition tree.
646
   *
647
   * @return The leaf that starts with the given text, or null if not found.
648
   */
649
  private VariableTreeItem<String> findLeaf( final String text ) {
650
    return getDefinitionPane().findLeaf( text );
651
  }
652
653
  /**
654
   * Used to ignore typed keys in favour of trapping pressed keys.
655
   *
656
   * @param e The key that was typed.
657
   */
658
  private void vModeKeyTyped( KeyEvent e ) {
659
    e.consume();
660
  }
661
662
  /**
663
   * Used to lazily initialize the keyboard map.
664
   *
665
   * @return Mappings for keyTyped and keyPressed.
666
   */
667
  protected InputMap<InputEvent> createKeyboardMap() {
668
    return sequence(
669
      consume( keyTyped(), this::vModeKeyTyped ),
670
      consume( keyPressed(), this::vModeKeyPressed )
671
    );
672
  }
673
674
  private InputMap<InputEvent> getKeyboardMap() {
675
    if( this.keyboardMap == null ) {
676
      this.keyboardMap = createKeyboardMap();
677
    }
678
679
    return this.keyboardMap;
680
  }
681
682
  /**
683
   * Collapses the tree then expands and selects the given node.
684
   *
685
   * @param node The node to expand.
686
   */
687
  private void expand( final TreeItem<String> node ) {
688
    final DefinitionPane pane = getDefinitionPane();
689
    pane.collapse();
690
    pane.expand( node );
691
    pane.select( node );
692
  }
693
694
  /**
695
   * Returns true iff the key code the user typed can be used as part of a YAML
696
   * variable name.
697
   *
698
   * @param keyEvent Keyboard key press event information.
699
   *
700
   * @return true The key is a value that can be inserted into the text.
701
   */
702
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
703
    final KeyCode kc = keyEvent.getCode();
704
705
    return (kc.isLetterKey()
706
      || kc.isDigitKey()
707
      || (keyEvent.isShiftDown() && kc == MINUS))
708
      && !keyEvent.isControlDown();
709
  }
710
711
  /**
712
   * Starts to capture user input events.
713
   */
714
  private void vModeStart() {
715
    addEventListener( getKeyboardMap() );
716
  }
717
718
  /**
719
   * Restores capturing of user input events to the previous event listener.
720
   * Also asks the processing chain to modify the variable text into a
721
   * machine-readable variable based on the format required by the file type.
722
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
723
   * file (.Rmd, .Rxml) will use `r#xVAR`.
724
   */
725
  private void vModeStop() {
726
    removeEventListener( getKeyboardMap() );
727
  }
728
729
  private VariableDecorator getVariableDecorator() {
730
    return new YamlVariableDecorator();
731
  }
732
733
  /**
734
   * Returns the index where the two strings diverge.
735
   *
736
   * @param s1 The string that could be a substring of s2, null allowed.
737
   * @param s2 The string that could be a substring of s1, null allowed.
738
   *
739
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
740
   * where they differ.
741
   */
742
  @SuppressWarnings( "StringEquality" )
743
  private int difference( final CharSequence s1, final CharSequence s2 ) {
744
    if( s1 == s2 ) {
745
      return NO_DIFFERENCE;
746
    }
747
748
    if( s1 == null || s2 == null ) {
749
      return 0;
750
    }
751
752
    int i = 0;
753
    final int limit = min( s1.length(), s2.length() );
754
755
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
756
      i++;
757
    }
758
759
    // If one string was shorter than the other, that's where they differ.
760
    return i;
761
  }
762
  
763
  private EditorPane getEditorPane() {
764
    return getFileEditorTab().getEditorPane();
765
  }
766
767
  /**
768
   * Delegates to the file editor pane, and, ultimately, to its text area.
769
   */
770
  private <T extends Event, U extends T> void addEventListener(
771
    final EventPattern<? super T, ? extends U> event,
772
    final Consumer<? super U> consumer ) {
773
    getEditorPane().addEventListener( event, consumer );
774
  }
775
776
  /**
777
   * Delegates to the file editor pane, and, ultimately, to its text area.
778
   *
779
   * @param map The map of methods to events.
780
   */
781
  private void addEventListener( final InputMap<InputEvent> map ) {
782
    getEditorPane().addEventListener( map );
783
  }
784
785
  private void removeEventListener( final InputMap<InputEvent> map ) {
786
    getEditorPane().removeEventListener( map );
787
  }
788
789
  /**
790
   * Returns the position of the caret when variable mode editing was requested.
791
   *
792
   * @return The variable mode caret position.
793
   */
794
  private int getInitialCaretPosition() {
795
    return this.initialCaretPosition;
796
  }
797
798
  /**
799
   * Sets the position of the caret when variable mode editing was requested.
800
   * Stores the current position because only the text that comes afterwards is
801
   * a suitable variable reference.
802
   *
803
   * @return The variable mode caret position.
804
   */
805
  private void setInitialCaretPosition() {
806
    this.initialCaretPosition = getEditor().getCaretPosition();
807
  }
808
809
  private StyledTextArea getEditor() {
810
    return getFileEditorTab().getEditorPane().getEditor();
811
  }
812
813
  public FileEditorTab getFileEditorTab() {
814
    return this.tab;
815
  }
816
817
  private void setFileEditorTab( final FileEditorTab editorTab ) {
818
    this.tab = editorTab;
815819
  }
816820