Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
A .idea/.gitignore
1
workspace.xml
12
M .idea/misc.xml
22
<project version="4">
33
  <component name="ExternalStorageConfigurationManager" enabled="true" />
4
  <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="false" project-jdk-name="JDK1.8" project-jdk-type="JavaSDK">
4
  <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="false" project-jdk-name="14" project-jdk-type="JavaSDK">
55
    <output url="file://$PROJECT_DIR$/out" />
66
  </component>
D .idea/workspace.xml
1
<?xml version="1.0" encoding="UTF-8"?>
2
<project version="4">
3
  <component name="ChangeListManager">
4
    <list default="true" id="3dcf7c8f-87b5-4d25-a804-39da40a621b8" name="Default Changelist" comment="">
5
      <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/FileEditorTab.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/FileEditorTab.java" afterDir="false" />
6
    </list>
7
    <option name="SHOW_DIALOG" value="false" />
8
    <option name="HIGHLIGHT_CONFLICTS" value="true" />
9
    <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
10
    <option name="LAST_RESOLUTION" value="IGNORE" />
11
  </component>
12
  <component name="ExternalProjectsData">
13
    <projectState path="$PROJECT_DIR$">
14
      <ProjectState />
15
    </projectState>
16
  </component>
17
  <component name="ExternalProjectsManager">
18
    <system id="GRADLE">
19
      <state>
20
        <task path="$PROJECT_DIR$">
21
          <activation />
22
        </task>
23
        <projects_view>
24
          <tree_state>
25
            <expand>
26
              <path>
27
                <item name="" type="6a2764b6:ExternalProjectsStructure$RootNode" />
28
                <item name="scrivenvar" type="f1a62948:ProjectNode" />
29
              </path>
30
              <path>
31
                <item name="" type="6a2764b6:ExternalProjectsStructure$RootNode" />
32
                <item name="scrivenvar" type="f1a62948:ProjectNode" />
33
                <item name="Tasks" type="e4a08cd1:TasksNode" />
34
              </path>
35
              <path>
36
                <item name="" type="6a2764b6:ExternalProjectsStructure$RootNode" />
37
                <item name="scrivenvar" type="f1a62948:ProjectNode" />
38
                <item name="Run Configurations" type="7b0102dc:RunConfigurationsNode" />
39
              </path>
40
            </expand>
41
            <select />
42
          </tree_state>
43
        </projects_view>
44
      </state>
45
    </system>
46
  </component>
47
  <component name="FileTemplateManagerImpl">
48
    <option name="RECENT_TEMPLATES">
49
      <list>
50
        <option value="Class" />
51
      </list>
52
    </option>
53
  </component>
54
  <component name="Git.Settings">
55
    <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
56
  </component>
57
  <component name="ProjectId" id="1bxOWN6JNt3E3hwxZwoPKSZNdNc" />
58
  <component name="ProjectLevelVcsManager" settingsEditedManually="true" />
59
  <component name="ProjectViewState">
60
    <option name="hideEmptyMiddlePackages" value="true" />
61
    <option name="showLibraryContents" value="true" />
62
  </component>
63
  <component name="PropertiesComponent">
64
    <property name="ASKED_ADD_EXTERNAL_FILES" value="true" />
65
    <property name="RunOnceActivity.OpenProjectViewOnStart" value="true" />
66
    <property name="RunOnceActivity.ShowReadmeOnStart" value="true" />
67
    <property name="SHARE_PROJECT_CONFIGURATION_FILES" value="true" />
68
    <property name="com.android.tools.idea.instantapp.provision.ProvisionBeforeRunTaskProvider.myTimeStamp" value="1541653415064" />
69
    <property name="last_opened_file_path" value="$PROJECT_DIR$" />
70
    <property name="settings.editor.selected.configurable" value="reference.settingsdialog.project.gradle" />
71
  </component>
72
  <component name="RunManager" selected="Application.Launcher">
73
    <configuration name="Launcher" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
74
      <option name="MAIN_CLASS_NAME" value="com.scrivenvar.Launcher" />
75
      <module name="scrivenvar_main" />
76
      <extension name="coverage">
77
        <pattern>
78
          <option name="PATTERN" value="com.scrivenvar.*" />
79
          <option name="ENABLED" value="true" />
80
        </pattern>
81
      </extension>
82
      <method v="2">
83
        <option name="Make" enabled="true" />
84
      </method>
85
    </configuration>
86
    <configuration name="Main" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
87
      <option name="MAIN_CLASS_NAME" value="com.scrivenvar.Main" />
88
      <module name="scrivenvar_main" />
89
      <extension name="coverage">
90
        <pattern>
91
          <option name="PATTERN" value="com.scrivenvar.*" />
92
          <option name="ENABLED" value="true" />
93
        </pattern>
94
      </extension>
95
      <method v="2">
96
        <option name="Make" enabled="true" />
97
      </method>
98
    </configuration>
99
    <configuration name="Scrivenvar" type="GradleRunConfiguration" factoryName="Gradle">
100
      <ExternalSystemSettings>
101
        <option name="executionName" />
102
        <option name="externalProjectPath" value="$PROJECT_DIR$" />
103
        <option name="externalSystemIdString" value="GRADLE" />
104
        <option name="scriptParameters" value="" />
105
        <option name="taskDescriptions">
106
          <list />
107
        </option>
108
        <option name="taskNames">
109
          <list>
110
            <option value="run" />
111
          </list>
112
        </option>
113
        <option name="vmOptions" value="" />
114
      </ExternalSystemSettings>
115
      <GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
116
      <method v="2" />
117
    </configuration>
118
    <configuration default="true" type="GradleRunConfiguration" factoryName="Gradle">
119
      <ExternalSystemSettings>
120
        <option name="executionName" />
121
        <option name="externalProjectPath" value="" />
122
        <option name="externalSystemIdString" value="GRADLE" />
123
        <option name="scriptParameters" value="" />
124
        <option name="taskDescriptions">
125
          <list />
126
        </option>
127
        <option name="taskNames">
128
          <list />
129
        </option>
130
        <option name="vmOptions" value="" />
131
      </ExternalSystemSettings>
132
      <GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
133
      <method v="2" />
134
    </configuration>
135
    <recent_temporary>
136
      <list>
137
        <item itemvalue="Application.Launcher" />
138
        <item itemvalue="Application.Main" />
139
      </list>
140
    </recent_temporary>
141
  </component>
142
  <component name="SvnConfiguration">
143
    <configuration />
144
  </component>
145
  <component name="TaskManager">
146
    <task active="true" id="Default" summary="Default task">
147
      <changelist id="3dcf7c8f-87b5-4d25-a804-39da40a621b8" name="Default Changelist" comment="" />
148
      <created>1541651873782</created>
149
      <option name="number" value="Default" />
150
      <option name="presentableId" value="Default" />
151
      <updated>1541651873782</updated>
152
    </task>
153
    <servers />
154
  </component>
155
  <component name="WindowStateProjectService">
156
    <state x="521" y="258" width="605" height="787" key="#Scrivenvar" timestamp="1589659082008">
157
      <screen x="0" y="28" width="2560" height="1529" />
158
    </state>
159
    <state x="521" y="258" width="605" height="787" key="#Scrivenvar/0.28.2560.1529@0.28.2560.1529" timestamp="1589659082008" />
160
    <state x="285" y="311" key="#com.intellij.execution.impl.EditConfigurationsDialog" timestamp="1589659128840">
161
      <screen x="0" y="28" width="2560" height="1529" />
162
    </state>
163
    <state x="285" y="311" key="#com.intellij.execution.impl.EditConfigurationsDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1589659128840" />
164
    <state x="635" y="363" width="376" height="578" key="#com.intellij.ide.util.MemberChooser" timestamp="1589658771205">
165
      <screen x="0" y="28" width="2560" height="1529" />
166
    </state>
167
    <state x="635" y="363" width="376" height="578" key="#com.intellij.ide.util.MemberChooser/0.28.2560.1529@0.28.2560.1529" timestamp="1589658771205" />
168
    <state x="468" y="28" width="711" height="1526" key="#com.intellij.refactoring.rename.AutomaticRenamingDialog" timestamp="1590855861709">
169
      <screen x="0" y="28" width="2560" height="1529" />
170
    </state>
171
    <state x="468" y="28" width="711" height="1526" key="#com.intellij.refactoring.rename.AutomaticRenamingDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1590855861709" />
172
    <state x="610" y="411" width="426" height="481" key="FileChooserDialogImpl" timestamp="1589659107517">
173
      <screen x="0" y="28" width="2560" height="1529" />
174
    </state>
175
    <state x="610" y="411" width="426" height="481" key="FileChooserDialogImpl/0.28.2560.1529@0.28.2560.1529" timestamp="1589659107517" />
176
    <state width="1573" height="321" key="GridCell.Tab.0.bottom" timestamp="1590863344981">
177
      <screen x="0" y="28" width="2560" height="1529" />
178
    </state>
179
    <state width="1573" height="321" key="GridCell.Tab.0.bottom/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344981" />
180
    <state width="1573" height="321" key="GridCell.Tab.0.center" timestamp="1590863344980">
181
      <screen x="0" y="28" width="2560" height="1529" />
182
    </state>
183
    <state width="1573" height="321" key="GridCell.Tab.0.center/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344980" />
184
    <state width="1573" height="321" key="GridCell.Tab.0.left" timestamp="1590863344980">
185
      <screen x="0" y="28" width="2560" height="1529" />
186
    </state>
187
    <state width="1573" height="321" key="GridCell.Tab.0.left/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344980" />
188
    <state width="1573" height="321" key="GridCell.Tab.0.right" timestamp="1590863344981">
189
      <screen x="0" y="28" width="2560" height="1529" />
190
    </state>
191
    <state width="1573" height="321" key="GridCell.Tab.0.right/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344981" />
192
    <state width="1573" height="396" key="GridCell.Tab.1.bottom" timestamp="1590858353845">
193
      <screen x="0" y="28" width="2560" height="1529" />
194
    </state>
195
    <state width="1573" height="396" key="GridCell.Tab.1.bottom/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" />
196
    <state width="1573" height="396" key="GridCell.Tab.1.center" timestamp="1590858353845">
197
      <screen x="0" y="28" width="2560" height="1529" />
198
    </state>
199
    <state width="1573" height="396" key="GridCell.Tab.1.center/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" />
200
    <state width="1573" height="396" key="GridCell.Tab.1.left" timestamp="1590858353845">
201
      <screen x="0" y="28" width="2560" height="1529" />
202
    </state>
203
    <state width="1573" height="396" key="GridCell.Tab.1.left/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" />
204
    <state width="1573" height="396" key="GridCell.Tab.1.right" timestamp="1590858353845">
205
      <screen x="0" y="28" width="2560" height="1529" />
206
    </state>
207
    <state width="1573" height="396" key="GridCell.Tab.1.right/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" />
208
    <state x="324" y="288" key="SettingsEditor" timestamp="1589576619807">
209
      <screen x="0" y="28" width="2560" height="1529" />
210
    </state>
211
    <state x="324" y="288" key="SettingsEditor/0.28.2560.1529@0.28.2560.1529" timestamp="1589576619807" />
212
    <state x="1071" y="397" width="1417" height="979" key="com.intellij.history.integration.ui.views.FileHistoryDialog" timestamp="1589661186060">
213
      <screen x="0" y="28" width="2560" height="1529" />
214
    </state>
215
    <state x="1071" y="397" width="1417" height="979" key="com.intellij.history.integration.ui.views.FileHistoryDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1589661186060" />
216
    <state x="714" y="633" key="com.intellij.openapi.vcs.update.UpdateOrStatusOptionsDialogupdate-v2" timestamp="1590804130551">
217
      <screen x="0" y="28" width="2560" height="1529" />
218
    </state>
219
    <state x="714" y="633" key="com.intellij.openapi.vcs.update.UpdateOrStatusOptionsDialogupdate-v2/0.28.2560.1529@0.28.2560.1529" timestamp="1590804130551" />
220
    <state x="531" y="261" width="586" height="753" key="find.popup" timestamp="1590803334947">
221
      <screen x="0" y="28" width="2560" height="1529" />
222
    </state>
223
    <state x="531" y="261" width="586" height="753" key="find.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1590803334947" />
224
    <state x="533" y="414" width="581" height="476" key="refactoring.ChangeSignatureDialog" timestamp="1589663937037">
225
      <screen x="0" y="28" width="2560" height="1529" />
226
    </state>
227
    <state x="533" y="414" width="581" height="476" key="refactoring.ChangeSignatureDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1589663937037" />
228
    <state x="490" y="304" key="run.anything.popup" timestamp="1589657324666">
229
      <screen x="0" y="28" width="2560" height="1529" />
230
    </state>
231
    <state x="490" y="304" key="run.anything.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1589657324666" />
232
    <state x="490" y="327" width="672" height="678" key="search.everywhere.popup" timestamp="1590858282498">
233
      <screen x="0" y="28" width="2560" height="1529" />
234
    </state>
235
    <state x="490" y="327" width="672" height="678" key="search.everywhere.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1590858282498" />
236
  </component>
237
  <component name="XDebuggerManager">
238
    <watches-manager>
239
      <configuration name="Remote">
240
        <watch expression="getMatcher().matches( ( new File (&quot;./untitled.md&quot;)).toPath() )" language="JAVA" />
241
      </configuration>
242
    </watches-manager>
243
  </component>
244
  <component name="masterDetails">
245
    <states>
246
      <state key="ProjectJDKs.UI">
247
        <settings>
248
          <last-edited>JDK1.8</last-edited>
249
          <splitter-proportions>
250
            <option name="proportions">
251
              <list>
252
                <option value="0.2" />
253
              </list>
254
            </option>
255
          </splitter-proportions>
256
        </settings>
257
      </state>
258
    </states>
259
  </component>
260
</project>
1
M USAGE-R.md
8282
1. Move `library.R` into the directory.
8383
1. Append a new function to `library.R` as follows:
84
``` r
85
mul <- function( a, b ) {
86
  a * b
87
}
88
```
84
    ``` r
85
    mul <- function( a, b ) {
86
      a * b
87
    }
88
    ```
8989
1. Click **R → Script**.
9090
1. Set the **R Startup Script** contents to:
M _config.yaml
1
application:
2
  title: Scrivenvar
3
1
---
2
River: "Door"
3
Ocean: "Floor"
44
M build.gradle
1818
1919
dependencies {
20
  implementation 'org.controlsfx:controlsfx:11.0.0'
20
  implementation 'org.controlsfx:controlsfx:11.0.1'
2121
  implementation 'org.fxmisc.richtext:richtextfx:0.10.5'
2222
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
2323
  implementation 'com.miglayout:miglayout-javafx:5.2'
24
  implementation 'com.vladsch.flexmark:flexmark:0.61.28'
25
  implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.61.28'
26
  implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.61.28'
27
  implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.61.28'
24
  implementation 'com.vladsch.flexmark:flexmark:0.62.0'
25
  implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.0'
26
  implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.0'
27
  implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.0'
2828
  implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0'
2929
  implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.0'
...
4848
    }
4949
  }
50
51
  testImplementation('org.junit.jupiter:junit-jupiter-api:5.4.2')
52
  testRuntime('org.junit.jupiter:junit-jupiter-engine:5.4.2')
5053
}
5154
...
6063
6164
sourceCompatibility = JavaVersion.VERSION_11
62
version = '1.4.1'
65
version = '1.5.0'
6366
applicationName = 'scrivenvar'
6467
mainClassName = 'com.scrivenvar.Main'
...
9396
    }
9497
  }
98
}
99
100
test {
101
  useJUnitPlatform()
95102
}
96103
M src/main/java/com/scrivenvar/AbstractFileFactory.java
11
/*
2
 * Copyright 2016 White Magic Software, Ltd.
2
 * Copyright 2020 White Magic Software, Ltd.
33
 *
44
 * All rights reserved.
...
4646
public class AbstractFileFactory {
4747
48
  private static final String MSG_UNKNOWN_FILE_TYPE = "Unknown type '%s' for " +
49
      "file '%s'.";
48
  private static final String MSG_UNKNOWN_FILE_TYPE =
49
      "Unknown type '%s' for file '%s'.";
5050
5151
  private final Settings mSettings = Services.load( Settings.class );
M src/main/java/com/scrivenvar/AbstractPane.java
2828
package com.scrivenvar;
2929
30
import com.scrivenvar.service.Options;
31
32
import java.util.prefs.Preferences;
33
3430
import org.tbee.javafx.scene.layout.fxml.MigPane;
3531
3632
/**
37
 * Provides options to all subclasses.
33
 * Hides dependency on {@link MigPane} from subclasses.
3834
 *
3935
 * @author White Magic Software, Ltd.
4036
 */
4137
public abstract class AbstractPane extends MigPane {
42
43
  /**
44
   * The options loaded from the service.
45
   */
46
  private final Options options = Services.load( Options.class );
47
48
  /**
49
   * Returns the persistent options for user settings.
50
   *
51
   * @return A non-null instance.
52
   */
53
  protected Options getOptions() {
54
    return this.options;
55
  }
56
57
  /**
58
   * Returns a hierarchical set of preferences.
59
   *
60
   * @return A non-null instance.
61
   */
62
  protected Preferences getState() {
63
    return getOptions().getState();
64
  }
6538
}
6639
M src/main/java/com/scrivenvar/Constants.java
3030
import com.scrivenvar.service.Settings;
3131
32
import java.util.Collection;
33
3432
/**
3533
 * Defines application-wide default values.
...
4240
4341
  /**
44
   * Prevent instantiation, deliberately.
42
   * Prevent instantiation.
4543
   */
4644
  private Constants() {
...
5452
  private static int get( final String key, final int defaultValue ) {
5553
    return SETTINGS.getSetting( key, defaultValue );
56
  }
57
58
  @SuppressWarnings("SameParameterValue")
59
  private static Collection<String> getStringSettingList( final String key ) {
60
    return SETTINGS.getStringSettingList( key );
6154
  }
6255
6356
  // Bootstrapping...
64
  public static final String SETTINGS_NAME = "/com/scrivenvar/settings.properties";
57
  public static final String SETTINGS_NAME = "/com/scrivenvar/settings" +
58
      ".properties";
6559
6660
  public static final String APP_TITLE = get( "application.title" );
6761
  public static final String APP_BUNDLE_NAME = get( "application.messages" );
6862
6963
  // Prevent double events when updating files on Linux (save and timestamp).
70
  public static final int APP_WATCHDOG_TIMEOUT = get( "application.watchdog.timeout", 100 );
64
  public static final int APP_WATCHDOG_TIMEOUT = get(
65
      "application.watchdog.timeout",
66
      100 );
7167
7268
  public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" );
73
  public static final String STYLESHEET_MARKDOWN = get( "file.stylesheet.markdown" );
74
  public static final String STYLESHEET_PREVIEW = get( "file.stylesheet.preview" );
69
  public static final String STYLESHEET_MARKDOWN = get(
70
      "file.stylesheet.markdown" );
71
  public static final String STYLESHEET_PREVIEW = get( "file.stylesheet" +
72
                                                           ".preview" );
7573
7674
  public static final String FILE_LOGO_16 = get( "file.logo.16" );
...
9189
  // terminate these prefixes with a period.
9290
  public static final String GLOB_PREFIX_FILE = "file.ext";
93
  public static final String GLOB_PREFIX_DEFINITION = "definition." + GLOB_PREFIX_FILE;
94
95
  public static final Collection<String> GLOB_DEFINITION_EXTENSIONS
96
    = getStringSettingList( GLOB_PREFIX_FILE + ".definition" );
91
  public static final String GLOB_PREFIX_DEFINITION =
92
      "definition." + GLOB_PREFIX_FILE;
9793
9894
  // Different definition source protocols.
9995
  public static final String DEFINITION_PROTOCOL_UNKNOWN = "unknown";
10096
  public static final String DEFINITION_PROTOCOL_FILE = "file";
10197
10298
  // Takes two parameters: line number and column number.
10399
  public static final String STATUS_BAR_LINE = "Main.statusbar.line";
104100
105101
  // "OK" text
106
  public static final String STATUS_BAR_DEFAULT = get( "Main.statusbar.state.default" );
102
  public static final String STATUS_BAR_OK = "Main.statusbar.state.default";
107103
  public static final String STATUS_PARSE_ERROR = "Main.statusbar.parse.error";
104
105
  /**
106
   * Used when creating flat maps relating to resolved variables.
107
   */
108
  public static final int DEFAULT_MAP_SIZE = 64;
108109
109110
  /**
M src/main/java/com/scrivenvar/FileEditorTab.java
4949
import org.mozilla.universalchardet.UniversalDetector;
5050
51
import java.io.IOException;
52
import java.nio.charset.Charset;
53
import java.nio.file.Files;
54
import java.nio.file.Path;
55
56
import static java.nio.charset.StandardCharsets.UTF_8;
57
import static java.util.Locale.ENGLISH;
58
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
59
60
/**
61
 * Editor for a single file.
62
 *
63
 * @author Karl Tauber and White Magic Software, Ltd.
64
 */
65
public final class FileEditorTab extends Tab {
66
67
  private final Notifier mAlertService = Services.load( Notifier.class );
68
  private final EditorPane mEditorPane = new MarkdownEditorPane();
69
70
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
71
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
72
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
73
74
  /**
75
   * Character encoding used by the file (or default encoding if none found).
76
   */
77
  private Charset mEncoding = UTF_8;
78
79
  /**
80
   * File to load into the editor.
81
   */
82
  private Path mPath;
83
84
  public FileEditorTab( final Path path ) {
85
    setPath( path );
86
87
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
88
89
    setOnSelectionChanged( e -> {
90
      if( isSelected() ) {
91
        Platform.runLater( this::activated );
92
      }
93
    } );
94
  }
95
96
  private void updateTab() {
97
    setText( getTabTitle() );
98
    setGraphic( getModifiedMark() );
99
    setTooltip( getTabTooltip() );
100
  }
101
102
  /**
103
   * Returns the base filename (without the directory names).
104
   *
105
   * @return The untitled text if the path hasn't been set.
106
   */
107
  private String getTabTitle() {
108
    final Path filePath = getPath();
109
110
    return (filePath == null)
111
        ? Messages.get( "FileEditor.untitled" )
112
        : filePath.getFileName().toString();
113
  }
114
115
  /**
116
   * Returns the full filename represented by the path.
117
   *
118
   * @return The untitled text if the path hasn't been set.
119
   */
120
  private Tooltip getTabTooltip() {
121
    final Path filePath = getPath();
122
    return new Tooltip( filePath == null ? "" : filePath.toString() );
123
  }
124
125
  /**
126
   * Returns a marker to indicate whether the file has been modified.
127
   *
128
   * @return "*" when the file has changed; otherwise null.
129
   */
130
  private Text getModifiedMark() {
131
    return isModified() ? new Text( "*" ) : null;
132
  }
133
134
  /**
135
   * Called when the user switches tab.
136
   */
137
  private void activated() {
138
    // Tab is closed or no longer active.
139
    if( getTabPane() == null || !isSelected() ) {
140
      return;
141
    }
142
143
    // Switch to the tab without loading if the contents are already in memory.
144
    if( getContent() != null ) {
145
      getEditorPane().requestFocus();
146
      return;
147
    }
148
149
    // Load the text and update the preview before the undo manager.
150
    load();
151
152
    // Track undo requests -- can only be called *after* load.
153
    initUndoManager();
154
    initLayout();
155
    initFocus();
156
  }
157
158
  private void initLayout() {
159
    setContent( getScrollPane() );
160
  }
161
162
  private Node getScrollPane() {
163
    return getEditorPane().getScrollPane();
164
  }
165
166
  private void initFocus() {
167
    getEditorPane().requestFocus();
168
  }
169
170
  private void initUndoManager() {
171
    final UndoManager<?> undoManager = getUndoManager();
172
    undoManager.forgetHistory();
173
174
    // Bind the editor undo manager to the properties.
175
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
176
    canUndo.bind( undoManager.undoAvailableProperty() );
177
    canRedo.bind( undoManager.redoAvailableProperty() );
178
  }
179
180
  /**
181
   * Searches from the caret position forward for the given string.
182
   *
183
   * @param needle The text string to match.
184
   */
185
  public void searchNext( final String needle ) {
186
    final String haystack = getEditorText();
187
    int index = haystack.indexOf( needle, getCaretPosition() );
188
189
    // Wrap around.
190
    if( index == -1 ) {
191
      index = haystack.indexOf( needle );
192
    }
193
194
    if( index >= 0 ) {
195
      setCaretPosition( index );
196
      getEditor().selectRange( index, index + needle.length() );
197
    }
198
  }
199
200
  /**
201
   * Returns the index into the text where the caret blinks happily away.
202
   *
203
   * @return A number from 0 to the editor's document text length.
204
   */
205
  public int getCaretPosition() {
206
    return getEditor().getCaretPosition();
207
  }
208
209
  /**
210
   * Moves the caret to a given offset.
211
   *
212
   * @param offset The new caret offset.
213
   */
214
  private void setCaretPosition( final int offset ) {
215
    getEditor().moveTo( offset );
216
    getEditor().requestFollowCaret();
217
  }
218
219
  /**
220
   * Returns the caret's current row and column position.
221
   *
222
   * @return The caret's offset into the document.
223
   */
224
  public Position getCaretOffset() {
225
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
226
  }
227
228
  /**
229
   * Allows observers to synchronize caret position changes.
230
   *
231
   * @return An observable caret property value.
232
   */
233
  public final ObservableValue<Integer> caretPositionProperty() {
234
    return getEditor().caretPositionProperty();
235
  }
236
237
  /**
238
   * Returns the text area associated with this tab.
239
   *
240
   * @return A text editor.
241
   */
242
  private StyleClassedTextArea getEditor() {
243
    return getEditorPane().getEditor();
244
  }
245
246
  /**
247
   * Returns true if the given path exactly matches this tab's path.
248
   *
249
   * @param check The path to compare against.
250
   * @return true The paths are the same.
251
   */
252
  public boolean isPath( final Path check ) {
253
    final Path filePath = getPath();
254
255
    return filePath != null && filePath.equals( check );
256
  }
257
258
  /**
259
   * Reads the entire file contents from the path associated with this tab.
260
   */
261
  private void load() {
262
    final Path filePath = getPath();
263
264
    if( filePath != null ) {
265
      try {
266
        getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) );
267
        getEditorPane().scrollToTop();
268
      } catch( final Exception ex ) {
269
        getNotifyService().notify( ex );
270
      }
271
    }
272
  }
273
274
  /**
275
   * Saves the entire file contents from the path associated with this tab.
276
   *
277
   * @return true The file has been saved.
278
   */
279
  public boolean save() {
280
    try {
281
      final EditorPane editor = getEditorPane();
282
      Files.write( getPath(), asBytes( editor.getText() ) );
283
      editor.getUndoManager().mark();
284
      return true;
285
    } catch( final IOException ex ) {
286
      return alert(
287
          "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex
288
      );
289
    }
290
  }
291
292
  /**
293
   * Creates an alert dialog and waits for it to close.
294
   *
295
   * @param titleKey   Resource bundle key for the alert dialog title.
296
   * @param messageKey Resource bundle key for the alert dialog message.
297
   * @param e          The unexpected happening.
298
   * @return false
299
   */
300
  @SuppressWarnings("SameParameterValue")
301
  private boolean alert(
302
      final String titleKey, final String messageKey, final Exception e ) {
303
    final Notifier service = getNotifyService();
304
    final Path filePath = getPath();
305
306
    final Notification message = service.createNotification(
307
        Messages.get( titleKey ),
308
        Messages.get( messageKey ),
309
        filePath == null ? "" : filePath,
310
        e.getMessage()
311
    );
312
313
    try {
314
      service.createError( getWindow(), message ).showAndWait();
315
    } catch( final Exception ex ) {
316
      getNotifyService().notify( ex );
317
    }
318
319
    return false;
320
  }
321
322
  private Window getWindow() {
323
    final Scene scene = getEditorPane().getScene();
324
325
    if( scene == null ) {
326
      throw new UnsupportedOperationException( "No scene window available" );
327
    }
328
329
    return scene.getWindow();
330
  }
331
332
  /**
333
   * Returns a best guess at the file encoding. If the encoding could not be
334
   * detected, this will return the default charset for the JVM.
335
   *
336
   * @param bytes The bytes to perform character encoding detection.
337
   * @return The character encoding.
338
   */
339
  private Charset detectEncoding( final byte[] bytes ) {
340
    final UniversalDetector detector = new UniversalDetector( null );
341
    detector.handleData( bytes, 0, bytes.length );
342
    detector.dataEnd();
343
344
    final String charset = detector.getDetectedCharset();
345
    final Charset charEncoding = charset == null
346
        ? Charset.defaultCharset()
347
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
348
349
    detector.reset();
350
351
    return charEncoding;
352
  }
353
354
  /**
355
   * Converts the given string to an array of bytes using the encoding that was
356
   * originally detected (if any) and associated with this file.
357
   *
358
   * @param text The text to convert into the original file encoding.
359
   * @return A series of bytes ready for writing to a file.
360
   */
361
  private byte[] asBytes( final String text ) {
362
    return text.getBytes( getEncoding() );
363
  }
364
365
  /**
366
   * Converts the given bytes into a Java String. This will call setEncoding
367
   * with the encoding detected by the CharsetDetector.
368
   *
369
   * @param text The text of unknown character encoding.
370
   * @return The text, in its auto-detected encoding, as a String.
371
   */
372
  private String asString( final byte[] text ) {
373
    setEncoding( detectEncoding( text ) );
374
    return new String( text, getEncoding() );
375
  }
376
377
  /**
378
   * Returns the path to the file being edited in this tab.
379
   *
380
   * @return A non-null instance.
381
   */
382
  public Path getPath() {
383
    return mPath;
384
  }
385
386
  /**
387
   * Sets the path to a file for editing and then updates the tab with the
388
   * file contents.
389
   *
390
   * @param path A non-null instance.
391
   */
392
  public void setPath( final Path path ) {
393
    assert path != null;
394
395
    mPath = path;
396
397
    updateTab();
398
  }
399
400
  public boolean isModified() {
401
    return mModified.get();
402
  }
403
404
  ReadOnlyBooleanProperty modifiedProperty() {
405
    return mModified.getReadOnlyProperty();
406
  }
407
408
  BooleanProperty canUndoProperty() {
409
    return this.canUndo;
410
  }
411
412
  BooleanProperty canRedoProperty() {
413
    return this.canRedo;
414
  }
415
416
  private UndoManager<?> getUndoManager() {
417
    return getEditorPane().getUndoManager();
418
  }
419
420
  /**
421
   * Forwards to the editor pane's listeners for text change events.
422
   *
423
   * @param listener The listener to notify when the text changes.
424
   */
425
  public void addTextChangeListener( final ChangeListener<String> listener ) {
426
    getEditorPane().addTextChangeListener( listener );
427
  }
428
429
  /**
430
   * Forwards to the editor pane's listeners for caret paragraph change events.
431
   *
432
   * @param listener The listener to notify when the caret changes paragraphs.
433
   */
434
  public void addCaretParagraphListener(
435
      final ChangeListener<Integer> listener ) {
436
    getEditorPane().addCaretParagraphListener( listener );
437
  }
438
439
  /**
440
   * Forwards the request to the editor pane.
441
   *
442
   * @return The text to process.
443
   */
444
  public String getEditorText() {
445
    return getEditorPane().getText();
446
  }
447
448
  /**
449
   * Returns the editor pane, or creates one if it doesn't yet exist.
450
   *
451
   * @return The editor pane, never null.
452
   */
453
  public EditorPane getEditorPane() {
454
    return mEditorPane;
455
  }
456
457
  private Notifier getNotifyService() {
458
    return mAlertService;
459
  }
460
461
  /**
462
   * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been
463
   * determined.
464
   *
465
   * @return The file encoding or UTF-8 if unknown.
466
   */
467
  private Charset getEncoding() {
468
    return mEncoding;
469
  }
470
471
  private void setEncoding( final Charset encoding ) {
472
    assert encoding != null;
473
474
    mEncoding = encoding;
51
import java.io.File;
52
import java.io.IOException;
53
import java.nio.charset.Charset;
54
import java.nio.file.Files;
55
import java.nio.file.Path;
56
57
import static com.scrivenvar.Messages.get;
58
import static java.nio.charset.StandardCharsets.UTF_8;
59
import static java.util.Locale.ENGLISH;
60
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
61
62
/**
63
 * Editor for a single file.
64
 *
65
 * @author Karl Tauber and White Magic Software, Ltd.
66
 */
67
public final class FileEditorTab extends Tab {
68
69
  private final Notifier mNotifier = Services.load( Notifier.class );
70
  private final EditorPane mEditorPane = new MarkdownEditorPane();
71
72
  private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper();
73
  private final BooleanProperty canUndo = new SimpleBooleanProperty();
74
  private final BooleanProperty canRedo = new SimpleBooleanProperty();
75
76
  /**
77
   * Character encoding used by the file (or default encoding if none found).
78
   */
79
  private Charset mEncoding = UTF_8;
80
81
  /**
82
   * File to load into the editor.
83
   */
84
  private Path mPath;
85
86
  public FileEditorTab( final Path path ) {
87
    setPath( path );
88
89
    mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() );
90
91
    setOnSelectionChanged( e -> {
92
      if( isSelected() ) {
93
        Platform.runLater( this::activated );
94
      }
95
    } );
96
  }
97
98
  private void updateTab() {
99
    setText( getTabTitle() );
100
    setGraphic( getModifiedMark() );
101
    setTooltip( getTabTooltip() );
102
  }
103
104
  /**
105
   * Returns the base filename (without the directory names).
106
   *
107
   * @return The untitled text if the path hasn't been set.
108
   */
109
  private String getTabTitle() {
110
    return getPath().getFileName().toString();
111
  }
112
113
  /**
114
   * Returns the full filename represented by the path.
115
   *
116
   * @return The untitled text if the path hasn't been set.
117
   */
118
  private Tooltip getTabTooltip() {
119
    final Path filePath = getPath();
120
    return new Tooltip( filePath == null ? "" : filePath.toString() );
121
  }
122
123
  /**
124
   * Returns a marker to indicate whether the file has been modified.
125
   *
126
   * @return "*" when the file has changed; otherwise null.
127
   */
128
  private Text getModifiedMark() {
129
    return isModified() ? new Text( "*" ) : null;
130
  }
131
132
  /**
133
   * Called when the user switches tab.
134
   */
135
  private void activated() {
136
    // Tab is closed or no longer active.
137
    if( getTabPane() == null || !isSelected() ) {
138
      return;
139
    }
140
141
    // Switch to the tab without loading if the contents are already in memory.
142
    if( getContent() != null ) {
143
      getEditorPane().requestFocus();
144
      return;
145
    }
146
147
    // Load the text and update the preview before the undo manager.
148
    load();
149
150
    // Track undo requests -- can only be called *after* load.
151
    initUndoManager();
152
    initLayout();
153
    initFocus();
154
  }
155
156
  private void initLayout() {
157
    setContent( getScrollPane() );
158
  }
159
160
  private Node getScrollPane() {
161
    return getEditorPane().getScrollPane();
162
  }
163
164
  private void initFocus() {
165
    getEditorPane().requestFocus();
166
  }
167
168
  private void initUndoManager() {
169
    final UndoManager<?> undoManager = getUndoManager();
170
    undoManager.forgetHistory();
171
172
    // Bind the editor undo manager to the properties.
173
    mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) );
174
    canUndo.bind( undoManager.undoAvailableProperty() );
175
    canRedo.bind( undoManager.redoAvailableProperty() );
176
  }
177
178
  /**
179
   * Searches from the caret position forward for the given string.
180
   *
181
   * @param needle The text string to match.
182
   */
183
  public void searchNext( final String needle ) {
184
    final String haystack = getEditorText();
185
    int index = haystack.indexOf( needle, getCaretPosition() );
186
187
    // Wrap around.
188
    if( index == -1 ) {
189
      index = haystack.indexOf( needle );
190
    }
191
192
    if( index >= 0 ) {
193
      setCaretPosition( index );
194
      getEditor().selectRange( index, index + needle.length() );
195
    }
196
  }
197
198
  /**
199
   * Returns the index into the text where the caret blinks happily away.
200
   *
201
   * @return A number from 0 to the editor's document text length.
202
   */
203
  public int getCaretPosition() {
204
    return getEditor().getCaretPosition();
205
  }
206
207
  /**
208
   * Moves the caret to a given offset.
209
   *
210
   * @param offset The new caret offset.
211
   */
212
  private void setCaretPosition( final int offset ) {
213
    getEditor().moveTo( offset );
214
    getEditor().requestFollowCaret();
215
  }
216
217
  /**
218
   * Returns the caret's current row and column position.
219
   *
220
   * @return The caret's offset into the document.
221
   */
222
  public Position getCaretOffset() {
223
    return getEditor().offsetToPosition( getCaretPosition(), Forward );
224
  }
225
226
  /**
227
   * Allows observers to synchronize caret position changes.
228
   *
229
   * @return An observable caret property value.
230
   */
231
  public final ObservableValue<Integer> caretPositionProperty() {
232
    return getEditor().caretPositionProperty();
233
  }
234
235
  /**
236
   * Returns the text area associated with this tab.
237
   *
238
   * @return A text editor.
239
   */
240
  private StyleClassedTextArea getEditor() {
241
    return getEditorPane().getEditor();
242
  }
243
244
  /**
245
   * Returns true if the given path exactly matches this tab's path.
246
   *
247
   * @param check The path to compare against.
248
   * @return true The paths are the same.
249
   */
250
  public boolean isPath( final Path check ) {
251
    final Path filePath = getPath();
252
253
    return filePath != null && filePath.equals( check );
254
  }
255
256
  /**
257
   * Reads the entire file contents from the path associated with this tab.
258
   */
259
  private void load() {
260
    final Path path = getPath();
261
    final File file = path.toFile();
262
263
    try {
264
      if( file.exists() ) {
265
        if( file.canWrite() && file.canRead() ) {
266
          final EditorPane pane = getEditorPane();
267
          pane.setText( asString( Files.readAllBytes( path ) ) );
268
          pane.scrollToTop();
269
        }
270
        else {
271
          final String msg = get(
272
              "FileEditor.loadFailed.message",
273
              file.toString(),
274
              get( "FileEditor.loadFailed.reason.permissions" )
275
          );
276
          getNotifier().notify( msg );
277
        }
278
      }
279
    } catch( final Exception ex ) {
280
      getNotifier().notify( ex );
281
    }
282
  }
283
284
  /**
285
   * Saves the entire file contents from the path associated with this tab.
286
   *
287
   * @return true The file has been saved.
288
   */
289
  public boolean save() {
290
    try {
291
      final EditorPane editor = getEditorPane();
292
      Files.write( getPath(), asBytes( editor.getText() ) );
293
      editor.getUndoManager().mark();
294
      return true;
295
    } catch( final Exception ex ) {
296
      return alert(
297
          "FileEditor.saveFailed.title",
298
          "FileEditor.saveFailed.message",
299
          ex
300
      );
301
    }
302
  }
303
304
  /**
305
   * Creates an alert dialog and waits for it to close.
306
   *
307
   * @param titleKey   Resource bundle key for the alert dialog title.
308
   * @param messageKey Resource bundle key for the alert dialog message.
309
   * @param e          The unexpected happening.
310
   * @return false
311
   */
312
  @SuppressWarnings("SameParameterValue")
313
  private boolean alert(
314
      final String titleKey, final String messageKey, final Exception e ) {
315
    final Notifier service = getNotifier();
316
    final Path filePath = getPath();
317
318
    final Notification message = service.createNotification(
319
        get( titleKey ),
320
        get( messageKey ),
321
        filePath == null ? "" : filePath,
322
        e.getMessage()
323
    );
324
325
    try {
326
      service.createError( getWindow(), message ).showAndWait();
327
    } catch( final Exception ex ) {
328
      getNotifier().notify( ex );
329
    }
330
331
    return false;
332
  }
333
334
  private Window getWindow() {
335
    final Scene scene = getEditorPane().getScene();
336
337
    if( scene == null ) {
338
      throw new UnsupportedOperationException( "No scene window available" );
339
    }
340
341
    return scene.getWindow();
342
  }
343
344
  /**
345
   * Returns a best guess at the file encoding. If the encoding could not be
346
   * detected, this will return the default charset for the JVM.
347
   *
348
   * @param bytes The bytes to perform character encoding detection.
349
   * @return The character encoding.
350
   */
351
  private Charset detectEncoding( final byte[] bytes ) {
352
    final UniversalDetector detector = new UniversalDetector( null );
353
    detector.handleData( bytes, 0, bytes.length );
354
    detector.dataEnd();
355
356
    final String charset = detector.getDetectedCharset();
357
    final Charset charEncoding = charset == null
358
        ? Charset.defaultCharset()
359
        : Charset.forName( charset.toUpperCase( ENGLISH ) );
360
361
    detector.reset();
362
363
    return charEncoding;
364
  }
365
366
  /**
367
   * Converts the given string to an array of bytes using the encoding that was
368
   * originally detected (if any) and associated with this file.
369
   *
370
   * @param text The text to convert into the original file encoding.
371
   * @return A series of bytes ready for writing to a file.
372
   */
373
  private byte[] asBytes( final String text ) {
374
    return text.getBytes( getEncoding() );
375
  }
376
377
  /**
378
   * Converts the given bytes into a Java String. This will call setEncoding
379
   * with the encoding detected by the CharsetDetector.
380
   *
381
   * @param text The text of unknown character encoding.
382
   * @return The text, in its auto-detected encoding, as a String.
383
   */
384
  private String asString( final byte[] text ) {
385
    setEncoding( detectEncoding( text ) );
386
    return new String( text, getEncoding() );
387
  }
388
389
  /**
390
   * Returns the path to the file being edited in this tab.
391
   *
392
   * @return A non-null instance.
393
   */
394
  public Path getPath() {
395
    return mPath;
396
  }
397
398
  /**
399
   * Sets the path to a file for editing and then updates the tab with the
400
   * file contents.
401
   *
402
   * @param path A non-null instance.
403
   */
404
  public void setPath( final Path path ) {
405
    assert path != null;
406
407
    mPath = path;
408
409
    updateTab();
410
  }
411
412
  public boolean isModified() {
413
    return mModified.get();
414
  }
415
416
  ReadOnlyBooleanProperty modifiedProperty() {
417
    return mModified.getReadOnlyProperty();
418
  }
419
420
  BooleanProperty canUndoProperty() {
421
    return this.canUndo;
422
  }
423
424
  BooleanProperty canRedoProperty() {
425
    return this.canRedo;
426
  }
427
428
  private UndoManager<?> getUndoManager() {
429
    return getEditorPane().getUndoManager();
430
  }
431
432
  /**
433
   * Forwards to the editor pane's listeners for text change events.
434
   *
435
   * @param listener The listener to notify when the text changes.
436
   */
437
  public void addTextChangeListener( final ChangeListener<String> listener ) {
438
    getEditorPane().addTextChangeListener( listener );
439
  }
440
441
  /**
442
   * Forwards to the editor pane's listeners for caret paragraph change events.
443
   *
444
   * @param listener The listener to notify when the caret changes paragraphs.
445
   */
446
  public void addCaretParagraphListener(
447
      final ChangeListener<Integer> listener ) {
448
    getEditorPane().addCaretParagraphListener( listener );
449
  }
450
451
  /**
452
   * Forwards the request to the editor pane.
453
   *
454
   * @return The text to process.
455
   */
456
  public String getEditorText() {
457
    return getEditorPane().getText();
458
  }
459
460
  /**
461
   * Returns the editor pane, or creates one if it doesn't yet exist.
462
   *
463
   * @return The editor pane, never null.
464
   */
465
  public EditorPane getEditorPane() {
466
    return mEditorPane;
467
  }
468
469
  /**
470
   * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been
471
   * determined.
472
   *
473
   * @return The file encoding or UTF-8 if unknown.
474
   */
475
  private Charset getEncoding() {
476
    return mEncoding;
477
  }
478
479
  private void setEncoding( final Charset encoding ) {
480
    assert encoding != null;
481
482
    mEncoding = encoding;
483
  }
484
485
  private Notifier getNotifier() {
486
    return mNotifier;
475487
  }
476488
M src/main/java/com/scrivenvar/FileEditorTabPane.java
7777
      ".filter";
7878
79
  private final Options options = Services.load( Options.class );
80
  private final Settings settings = Services.load( Settings.class );
81
  private final Notifier notifyService = Services.load( Notifier.class );
82
83
  private final ReadOnlyObjectWrapper<Path> openDefinition =
84
      new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
86
      new ReadOnlyObjectWrapper<>();
87
  private final ReadOnlyBooleanWrapper anyFileEditorModified =
88
      new ReadOnlyBooleanWrapper();
89
90
  /**
91
   * Constructs a new file editor tab pane.
92
   */
93
  public FileEditorTabPane() {
94
    final ObservableList<Tab> tabs = getTabs();
95
96
    setFocusTraversable( false );
97
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
98
99
    addTabSelectionListener(
100
        ( ObservableValue<? extends Tab> tabPane,
101
          final Tab oldTab, final Tab newTab ) -> {
102
103
          if( newTab != null ) {
104
            mActiveFileEditor.set( (FileEditorTab) newTab );
105
          }
106
        }
107
    );
108
109
    final ChangeListener<Boolean> modifiedListener = ( observable, oldValue,
110
                                                       newValue ) -> {
111
      for( final Tab tab : tabs ) {
112
        if( ((FileEditorTab) tab).isModified() ) {
113
          this.anyFileEditorModified.set( true );
114
          break;
115
        }
116
      }
117
    };
118
119
    tabs.addListener(
120
        (ListChangeListener<Tab>) change -> {
121
          while( change.next() ) {
122
            if( change.wasAdded() ) {
123
              change.getAddedSubList().forEach(
124
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
125
                                                  .addListener( modifiedListener ) );
126
            }
127
            else if( change.wasRemoved() ) {
128
              change.getRemoved().forEach(
129
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
130
                                                  .removeListener(
131
                                                      modifiedListener ) );
132
            }
133
          }
134
135
          // Changes in the tabs may also change anyFileEditorModified property
136
          // (e.g. closed modified file)
137
          modifiedListener.changed( null, null, null );
138
        }
139
    );
140
  }
141
142
  /**
143
   * Allows observers to be notified when the current file editor tab changes.
144
   *
145
   * @param listener The listener to notify of tab change events.
146
   */
147
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
148
    // Observe the tab so that when a new tab is opened or selected,
149
    // a notification is kicked off.
150
    getSelectionModel().selectedItemProperty().addListener( listener );
151
  }
152
153
  /**
154
   * Allows clients to manipulate the editor content directly.
155
   *
156
   * @return The text area for the active file editor.
157
   */
158
  public StyledTextArea getEditor() {
159
    return getActiveFileEditor().getEditorPane().getEditor();
160
  }
161
162
  /**
163
   * Returns the tab that has keyboard focus.
164
   *
165
   * @return A non-null instance.
166
   */
167
  public FileEditorTab getActiveFileEditor() {
168
    return mActiveFileEditor.get();
169
  }
170
171
  /**
172
   * Returns the property corresponding to the tab that has focus.
173
   *
174
   * @return A non-null instance.
175
   */
176
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
177
    return mActiveFileEditor.getReadOnlyProperty();
178
  }
179
180
  /**
181
   * Property that can answer whether the text has been modified.
182
   *
183
   * @return A non-null instance, true meaning the content has not been saved.
184
   */
185
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
186
    return this.anyFileEditorModified.getReadOnlyProperty();
187
  }
188
189
  /**
190
   * Creates a new editor instance from the given path.
191
   *
192
   * @param path The file to open.
193
   * @return A non-null instance.
194
   */
195
  private FileEditorTab createFileEditor( final Path path ) {
196
    assert path != null;
197
198
    final FileEditorTab tab = new FileEditorTab( path );
199
200
    tab.setOnCloseRequest( e -> {
201
      if( !canCloseEditor( tab ) ) {
202
        e.consume();
203
      }
204
      else if( isActiveFileEditor( tab ) ) {
205
        // Prevent prompting the user to save when there are no file editor
206
        // tabs open.
207
        mActiveFileEditor.set( null );
208
      }
209
    } );
210
211
    return tab;
212
  }
213
214
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
215
    return getActiveFileEditor() == tab;
216
  }
217
218
  private Path getDefaultPath() {
219
    final String filename = getDefaultFilename();
220
    return (new File( filename )).toPath();
221
  }
222
223
  private String getDefaultFilename() {
224
    return getSettings().getSetting( "file.default", "untitled.md" );
225
  }
226
227
  /**
228
   * Called when the user selects New from the File menu.
229
   */
230
  void newEditor() {
231
    final Path defaultPath = getDefaultPath();
232
    final FileEditorTab tab = createFileEditor( defaultPath );
233
234
    getTabs().add( tab );
235
    getSelectionModel().select( tab );
236
  }
237
238
  void openFileDialog() {
239
    final String title = get( "Dialog.file.choose.open.title" );
240
    final FileChooser dialog = createFileChooser( title );
241
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
242
243
    if( files != null ) {
244
      openFiles( files );
245
    }
246
  }
247
248
  /**
249
   * Opens the files into new editors, unless one of those files was a
250
   * definition file. The definition file is loaded into the definition pane,
251
   * but only the first one selected (multiple definition files will result in a
252
   * warning).
253
   *
254
   * @param files The list of non-definition files that the were requested to
255
   *              open.
256
   */
257
  private void openFiles( final List<File> files ) {
258
    final List<String> extensions =
259
        createExtensionFilter( DEFINITION ).getExtensions();
260
    final FileTypePredicate predicate =
261
        new FileTypePredicate( extensions );
262
263
    // The user might have opened multiple definitions files. These will
264
    // be discarded from the text editable files.
265
    final List<File> definitions
266
        = files.stream().filter( predicate ).collect( Collectors.toList() );
267
268
    // Create a modifiable list to remove any definition files that were
269
    // opened.
270
    final List<File> editors = new ArrayList<>( files );
271
272
    if( !editors.isEmpty() ) {
273
      saveLastDirectory( editors.get( 0 ) );
274
    }
275
276
    editors.removeAll( definitions );
277
278
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
279
    if( !editors.isEmpty() ) {
280
      openEditors( editors, 0 );
281
    }
282
283
    if( !definitions.isEmpty() ) {
284
      openDefinition( definitions.get( 0 ) );
285
    }
286
  }
287
288
  private void openEditors( final List<File> files, final int activeIndex ) {
289
    final int fileTally = files.size();
290
    final List<Tab> tabs = getTabs();
291
292
    // Close single unmodified "Untitled" tab.
293
    if( tabs.size() == 1 ) {
294
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
295
296
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
297
        closeEditor( fileEditor, false );
298
      }
299
    }
300
301
    for( int i = 0; i < fileTally; i++ ) {
302
      final Path path = files.get( i ).toPath();
303
304
      FileEditorTab fileEditorTab = findEditor( path );
305
306
      // Only open new files.
307
      if( fileEditorTab == null ) {
308
        fileEditorTab = createFileEditor( path );
309
        getTabs().add( fileEditorTab );
310
      }
311
312
      // Select the first file in the list.
313
      if( i == activeIndex ) {
314
        getSelectionModel().select( fileEditorTab );
315
      }
316
    }
317
  }
318
319
  /**
320
   * Returns a property that changes when a new definition file is opened.
321
   *
322
   * @return The path to a definition file that was opened.
323
   */
324
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
325
    return getOnOpenDefinitionFile().getReadOnlyProperty();
326
  }
327
328
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
329
    return this.openDefinition;
330
  }
331
332
  /**
333
   * Called when the user has opened a definition file (using the file open
334
   * dialog box). This will replace the current set of definitions for the
335
   * active tab.
336
   *
337
   * @param definition The file to open.
338
   */
339
  private void openDefinition( final File definition ) {
340
    // TODO: Prevent reading this file twice when a new text document is opened.
341
    // (might be a matter of checking the value first).
342
    getOnOpenDefinitionFile().set( definition.toPath() );
343
  }
344
345
  /**
346
   * Called when the contents of the editor are to be saved.
347
   *
348
   * @param tab The tab containing content to save.
349
   * @return true The contents were saved (or needn't be saved).
350
   */
351
  public boolean saveEditor( final FileEditorTab tab ) {
352
    if( tab == null || !tab.isModified() ) {
353
      return true;
354
    }
355
356
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
357
  }
358
359
  /**
360
   * Opens the Save As dialog for the user to save the content under a new
361
   * path.
362
   *
363
   * @param tab The tab with contents to save.
364
   * @return true The contents were saved, or the tab was null.
365
   */
366
  public boolean saveEditorAs( final FileEditorTab tab ) {
367
    if( tab == null ) {
368
      return true;
369
    }
370
371
    getSelectionModel().select( tab );
372
373
    final FileChooser fileChooser = createFileChooser( get(
374
        "Dialog.file.choose.save.title" ) );
375
    final File file = fileChooser.showSaveDialog( getWindow() );
376
    if( file == null ) {
377
      return false;
378
    }
379
380
    saveLastDirectory( file );
381
    tab.setPath( file.toPath() );
382
383
    return tab.save();
384
  }
385
386
  void saveAllEditors() {
387
    for( final FileEditorTab fileEditor : getAllEditors() ) {
388
      saveEditor( fileEditor );
389
    }
390
  }
391
392
  /**
393
   * Answers whether the file has had modifications. '
394
   *
395
   * @param tab THe tab to check for modifications.
396
   * @return false The file is unmodified.
397
   */
398
  boolean canCloseEditor( final FileEditorTab tab ) {
399
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
400
    canClose.set( true );
401
402
    if( tab.isModified() ) {
403
      final Notification message = getNotifyService().createNotification(
404
          Messages.get( "Alert.file.close.title" ),
405
          Messages.get( "Alert.file.close.text" ),
406
          tab.getText()
407
      );
408
409
      final Alert confirmSave = getNotifyService().createConfirmation(
410
          getWindow(), message );
411
412
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
413
414
      buttonType.ifPresent(
415
          save -> canClose.set(
416
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
417
          )
418
      );
419
    }
420
421
    return canClose.get();
422
  }
423
424
  private Notifier getNotifyService() {
425
    return this.notifyService;
426
  }
427
428
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
429
    if( tab == null ) {
430
      return true;
431
    }
432
433
    if( save ) {
434
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
435
      Event.fireEvent( tab, event );
436
437
      if( event.isConsumed() ) {
438
        return false;
439
      }
440
    }
441
442
    getTabs().remove( tab );
443
444
    if( tab.getOnClosed() != null ) {
445
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
446
    }
447
448
    return true;
449
  }
450
451
  boolean closeAllEditors() {
452
    final FileEditorTab[] allEditors = getAllEditors();
453
    final FileEditorTab activeEditor = getActiveFileEditor();
454
455
    // try to save active tab first because in case the user decides to cancel,
456
    // then it stays active
457
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
458
      return false;
459
    }
460
461
    // This should be called any time a tab changes.
462
    persistPreferences();
463
464
    // save modified tabs
465
    for( int i = 0; i < allEditors.length; i++ ) {
466
      final FileEditorTab fileEditor = allEditors[ i ];
467
468
      if( fileEditor == activeEditor ) {
469
        continue;
470
      }
471
472
      if( fileEditor.isModified() ) {
473
        // activate the modified tab to make its modified content visible to
474
        // the user
475
        getSelectionModel().select( i );
476
477
        if( !canCloseEditor( fileEditor ) ) {
478
          return false;
479
        }
480
      }
481
    }
482
483
    // Close all tabs.
484
    for( final FileEditorTab fileEditor : allEditors ) {
485
      if( !closeEditor( fileEditor, false ) ) {
486
        return false;
487
      }
488
    }
489
490
    return getTabs().isEmpty();
491
  }
492
493
  private FileEditorTab[] getAllEditors() {
494
    final ObservableList<Tab> tabs = getTabs();
495
    final int length = tabs.size();
496
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
497
498
    for( int i = 0; i < length; i++ ) {
499
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
500
    }
501
502
    return allEditors;
503
  }
504
505
  /**
506
   * Returns the file editor tab that has the given path.
507
   *
508
   * @return null No file editor tab for the given path was found.
509
   */
510
  private FileEditorTab findEditor( final Path path ) {
511
    for( final Tab tab : getTabs() ) {
512
      final FileEditorTab fileEditor = (FileEditorTab) tab;
513
514
      if( fileEditor.isPath( path ) ) {
515
        return fileEditor;
516
      }
517
    }
518
519
    return null;
520
  }
521
522
  private FileChooser createFileChooser( String title ) {
523
    final FileChooser fileChooser = new FileChooser();
524
525
    fileChooser.setTitle( title );
526
    fileChooser.getExtensionFilters().addAll(
527
        createExtensionFilters() );
528
529
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
530
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
531
532
    if( !file.isDirectory() ) {
533
      file = new File( "." );
534
    }
535
536
    fileChooser.setInitialDirectory( file );
537
    return fileChooser;
538
  }
539
540
  private List<ExtensionFilter> createExtensionFilters() {
541
    final List<ExtensionFilter> list = new ArrayList<>();
542
543
    // TODO: Return a list of all properties that match the filter prefix.
544
    // This will allow dynamic filters to be added and removed just by
545
    // updating the properties file.
546
    list.add( createExtensionFilter( SOURCE ) );
547
    list.add( createExtensionFilter( DEFINITION ) );
548
    list.add( createExtensionFilter( XML ) );
549
    list.add( createExtensionFilter( ALL ) );
550
    return list;
551
  }
552
553
  /**
554
   * Returns a filter for file name extensions recognized by the application
555
   * that can be opened by the user.
556
   *
557
   * @param filetype Used to find the globbing pattern for extensions.
558
   * @return A filename filter suitable for use by a FileDialog instance.
559
   */
560
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
561
    final String tKey = String.format( "%s.title.%s",
562
                                       FILTER_EXTENSION_TITLES,
563
                                       filetype );
564
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
565
566
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
567
  }
568
569
  private List<String> getExtensions( final String key ) {
570
    return getSettings().getStringSettingList( key );
571
  }
572
573
  private void saveLastDirectory( final File file ) {
574
    getPreferences().put( "lastDirectory", file.getParent() );
575
  }
576
577
  public void restorePreferences() {
578
    int activeIndex = 0;
579
580
    final Preferences preferences = getPreferences();
581
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
582
    final String activeFileName = preferences.get( "activeFile", null );
583
584
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
585
586
    for( final String fileName : fileNames ) {
587
      final File file = new File( fileName );
588
589
      if( file.exists() ) {
590
        files.add( file );
591
592
        if( fileName.equals( activeFileName ) ) {
593
          activeIndex = files.size() - 1;
594
        }
595
      }
596
    }
597
598
    if( files.isEmpty() ) {
599
      newEditor();
600
    }
601
    else {
602
      openEditors( files, activeIndex );
603
    }
604
  }
605
606
  public void persistPreferences() {
607
    final ObservableList<Tab> allEditors = getTabs();
608
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
609
610
    for( final Tab tab : allEditors ) {
611
      final FileEditorTab fileEditor = (FileEditorTab) tab;
612
      final Path filePath = fileEditor.getPath();
613
614
      if( filePath != null ) {
615
        fileNames.add( filePath.toString() );
616
      }
617
    }
618
619
    final Preferences preferences = getPreferences();
620
    Utils.putPrefsStrings( preferences,
621
                           "file",
622
                           fileNames.toArray( new String[ 0 ] ) );
623
624
    final FileEditorTab activeEditor = getActiveFileEditor();
625
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
626
627
    if( filePath == null ) {
628
      preferences.remove( "activeFile" );
629
    }
630
    else {
631
      preferences.put( "activeFile", filePath.toString() );
632
    }
633
  }
634
635
  private Settings getSettings() {
636
    return this.settings;
637
  }
638
639
  protected Options getOptions() {
640
    return this.options;
79
  private final Options mOptions = Services.load( Options.class );
80
  private final Settings mSettings = Services.load( Settings.class );
81
  private final Notifier mNotifyService = Services.load( Notifier.class );
82
83
  private final ReadOnlyObjectWrapper<Path> openDefinition =
84
      new ReadOnlyObjectWrapper<>();
85
  private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor =
86
      new ReadOnlyObjectWrapper<>();
87
  private final ReadOnlyBooleanWrapper anyFileEditorModified =
88
      new ReadOnlyBooleanWrapper();
89
90
  /**
91
   * Constructs a new file editor tab pane.
92
   */
93
  public FileEditorTabPane() {
94
    final ObservableList<Tab> tabs = getTabs();
95
96
    setFocusTraversable( false );
97
    setTabClosingPolicy( TabClosingPolicy.ALL_TABS );
98
99
    addTabSelectionListener(
100
        ( ObservableValue<? extends Tab> tabPane,
101
          final Tab oldTab, final Tab newTab ) -> {
102
103
          if( newTab != null ) {
104
            mActiveFileEditor.set( (FileEditorTab) newTab );
105
          }
106
        }
107
    );
108
109
    final ChangeListener<Boolean> modifiedListener = ( observable, oldValue,
110
                                                       newValue ) -> {
111
      for( final Tab tab : tabs ) {
112
        if( ((FileEditorTab) tab).isModified() ) {
113
          this.anyFileEditorModified.set( true );
114
          break;
115
        }
116
      }
117
    };
118
119
    tabs.addListener(
120
        (ListChangeListener<Tab>) change -> {
121
          while( change.next() ) {
122
            if( change.wasAdded() ) {
123
              change.getAddedSubList().forEach(
124
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
125
                                                  .addListener( modifiedListener ) );
126
            }
127
            else if( change.wasRemoved() ) {
128
              change.getRemoved().forEach(
129
                  ( tab ) -> ((FileEditorTab) tab).modifiedProperty()
130
                                                  .removeListener(
131
                                                      modifiedListener ) );
132
            }
133
          }
134
135
          // Changes in the tabs may also change anyFileEditorModified property
136
          // (e.g. closed modified file)
137
          modifiedListener.changed( null, null, null );
138
        }
139
    );
140
  }
141
142
  /**
143
   * Allows observers to be notified when the current file editor tab changes.
144
   *
145
   * @param listener The listener to notify of tab change events.
146
   */
147
  public void addTabSelectionListener( final ChangeListener<Tab> listener ) {
148
    // Observe the tab so that when a new tab is opened or selected,
149
    // a notification is kicked off.
150
    getSelectionModel().selectedItemProperty().addListener( listener );
151
  }
152
153
  /**
154
   * Allows clients to manipulate the editor content directly.
155
   *
156
   * @return The text area for the active file editor.
157
   */
158
  public StyledTextArea getEditor() {
159
    return getActiveFileEditor().getEditorPane().getEditor();
160
  }
161
162
  /**
163
   * Returns the tab that has keyboard focus.
164
   *
165
   * @return A non-null instance.
166
   */
167
  public FileEditorTab getActiveFileEditor() {
168
    return mActiveFileEditor.get();
169
  }
170
171
  /**
172
   * Returns the property corresponding to the tab that has focus.
173
   *
174
   * @return A non-null instance.
175
   */
176
  public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() {
177
    return mActiveFileEditor.getReadOnlyProperty();
178
  }
179
180
  /**
181
   * Property that can answer whether the text has been modified.
182
   *
183
   * @return A non-null instance, true meaning the content has not been saved.
184
   */
185
  ReadOnlyBooleanProperty anyFileEditorModifiedProperty() {
186
    return this.anyFileEditorModified.getReadOnlyProperty();
187
  }
188
189
  /**
190
   * Creates a new editor instance from the given path.
191
   *
192
   * @param path The file to open.
193
   * @return A non-null instance.
194
   */
195
  private FileEditorTab createFileEditor( final Path path ) {
196
    assert path != null;
197
198
    final FileEditorTab tab = new FileEditorTab( path );
199
200
    tab.setOnCloseRequest( e -> {
201
      if( !canCloseEditor( tab ) ) {
202
        e.consume();
203
      }
204
      else if( isActiveFileEditor( tab ) ) {
205
        // Prevent prompting the user to save when there are no file editor
206
        // tabs open.
207
        mActiveFileEditor.set( null );
208
      }
209
    } );
210
211
    return tab;
212
  }
213
214
  private boolean isActiveFileEditor( final FileEditorTab tab ) {
215
    return getActiveFileEditor() == tab;
216
  }
217
218
  private Path getDefaultPath() {
219
    final String filename = getDefaultFilename();
220
    return (new File( filename )).toPath();
221
  }
222
223
  private String getDefaultFilename() {
224
    return getSettings().getSetting( "file.default", "untitled.md" );
225
  }
226
227
  /**
228
   * Called when the user selects New from the File menu.
229
   */
230
  void newEditor() {
231
    final Path defaultPath = getDefaultPath();
232
    final FileEditorTab tab = createFileEditor( defaultPath );
233
234
    getTabs().add( tab );
235
    getSelectionModel().select( tab );
236
  }
237
238
  void openFileDialog() {
239
    final String title = get( "Dialog.file.choose.open.title" );
240
    final FileChooser dialog = createFileChooser( title );
241
    final List<File> files = dialog.showOpenMultipleDialog( getWindow() );
242
243
    if( files != null ) {
244
      openFiles( files );
245
    }
246
  }
247
248
  /**
249
   * Opens the files into new editors, unless one of those files was a
250
   * definition file. The definition file is loaded into the definition pane,
251
   * but only the first one selected (multiple definition files will result in a
252
   * warning).
253
   *
254
   * @param files The list of non-definition files that the were requested to
255
   *              open.
256
   */
257
  private void openFiles( final List<File> files ) {
258
    final List<String> extensions =
259
        createExtensionFilter( DEFINITION ).getExtensions();
260
    final FileTypePredicate predicate =
261
        new FileTypePredicate( extensions );
262
263
    // The user might have opened multiple definitions files. These will
264
    // be discarded from the text editable files.
265
    final List<File> definitions
266
        = files.stream().filter( predicate ).collect( Collectors.toList() );
267
268
    // Create a modifiable list to remove any definition files that were
269
    // opened.
270
    final List<File> editors = new ArrayList<>( files );
271
272
    if( !editors.isEmpty() ) {
273
      saveLastDirectory( editors.get( 0 ) );
274
    }
275
276
    editors.removeAll( definitions );
277
278
    // Open editor-friendly files (e.g,. Markdown, XML) in new tabs.
279
    if( !editors.isEmpty() ) {
280
      openEditors( editors, 0 );
281
    }
282
283
    if( !definitions.isEmpty() ) {
284
      openDefinition( definitions.get( 0 ) );
285
    }
286
  }
287
288
  private void openEditors( final List<File> files, final int activeIndex ) {
289
    final int fileTally = files.size();
290
    final List<Tab> tabs = getTabs();
291
292
    // Close single unmodified "Untitled" tab.
293
    if( tabs.size() == 1 ) {
294
      final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 ));
295
296
      if( fileEditor.getPath() == null && !fileEditor.isModified() ) {
297
        closeEditor( fileEditor, false );
298
      }
299
    }
300
301
    for( int i = 0; i < fileTally; i++ ) {
302
      final Path path = files.get( i ).toPath();
303
304
      FileEditorTab fileEditorTab = findEditor( path );
305
306
      // Only open new files.
307
      if( fileEditorTab == null ) {
308
        fileEditorTab = createFileEditor( path );
309
        getTabs().add( fileEditorTab );
310
      }
311
312
      // Select the first file in the list.
313
      if( i == activeIndex ) {
314
        getSelectionModel().select( fileEditorTab );
315
      }
316
    }
317
  }
318
319
  /**
320
   * Returns a property that changes when a new definition file is opened.
321
   *
322
   * @return The path to a definition file that was opened.
323
   */
324
  public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() {
325
    return getOnOpenDefinitionFile().getReadOnlyProperty();
326
  }
327
328
  private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() {
329
    return this.openDefinition;
330
  }
331
332
  /**
333
   * Called when the user has opened a definition file (using the file open
334
   * dialog box). This will replace the current set of definitions for the
335
   * active tab.
336
   *
337
   * @param definition The file to open.
338
   */
339
  private void openDefinition( final File definition ) {
340
    // TODO: Prevent reading this file twice when a new text document is opened.
341
    // (might be a matter of checking the value first).
342
    getOnOpenDefinitionFile().set( definition.toPath() );
343
  }
344
345
  /**
346
   * Called when the contents of the editor are to be saved.
347
   *
348
   * @param tab The tab containing content to save.
349
   * @return true The contents were saved (or needn't be saved).
350
   */
351
  public boolean saveEditor( final FileEditorTab tab ) {
352
    if( tab == null || !tab.isModified() ) {
353
      return true;
354
    }
355
356
    return tab.getPath() == null ? saveEditorAs( tab ) : tab.save();
357
  }
358
359
  /**
360
   * Opens the Save As dialog for the user to save the content under a new
361
   * path.
362
   *
363
   * @param tab The tab with contents to save.
364
   * @return true The contents were saved, or the tab was null.
365
   */
366
  public boolean saveEditorAs( final FileEditorTab tab ) {
367
    if( tab == null ) {
368
      return true;
369
    }
370
371
    getSelectionModel().select( tab );
372
373
    final FileChooser fileChooser = createFileChooser( get(
374
        "Dialog.file.choose.save.title" ) );
375
    final File file = fileChooser.showSaveDialog( getWindow() );
376
    if( file == null ) {
377
      return false;
378
    }
379
380
    saveLastDirectory( file );
381
    tab.setPath( file.toPath() );
382
383
    return tab.save();
384
  }
385
386
  void saveAllEditors() {
387
    for( final FileEditorTab fileEditor : getAllEditors() ) {
388
      saveEditor( fileEditor );
389
    }
390
  }
391
392
  /**
393
   * Answers whether the file has had modifications. '
394
   *
395
   * @param tab THe tab to check for modifications.
396
   * @return false The file is unmodified.
397
   */
398
  @SuppressWarnings("BooleanMethodIsAlwaysInverted")
399
  boolean canCloseEditor( final FileEditorTab tab ) {
400
    final AtomicReference<Boolean> canClose = new AtomicReference<>();
401
    canClose.set( true );
402
403
    if( tab.isModified() ) {
404
      final Notification message = getNotifyService().createNotification(
405
          Messages.get( "Alert.file.close.title" ),
406
          Messages.get( "Alert.file.close.text" ),
407
          tab.getText()
408
      );
409
410
      final Alert confirmSave = getNotifyService().createConfirmation(
411
          getWindow(), message );
412
413
      final Optional<ButtonType> buttonType = confirmSave.showAndWait();
414
415
      buttonType.ifPresent(
416
          save -> canClose.set(
417
              save == YES ? saveEditor( tab ) : save == ButtonType.NO
418
          )
419
      );
420
    }
421
422
    return canClose.get();
423
  }
424
425
  private Notifier getNotifyService() {
426
    return this.mNotifyService;
427
  }
428
429
  boolean closeEditor( final FileEditorTab tab, final boolean save ) {
430
    if( tab == null ) {
431
      return true;
432
    }
433
434
    if( save ) {
435
      Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT );
436
      Event.fireEvent( tab, event );
437
438
      if( event.isConsumed() ) {
439
        return false;
440
      }
441
    }
442
443
    getTabs().remove( tab );
444
445
    if( tab.getOnClosed() != null ) {
446
      Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) );
447
    }
448
449
    return true;
450
  }
451
452
  boolean closeAllEditors() {
453
    final FileEditorTab[] allEditors = getAllEditors();
454
    final FileEditorTab activeEditor = getActiveFileEditor();
455
456
    // try to save active tab first because in case the user decides to cancel,
457
    // then it stays active
458
    if( activeEditor != null && !canCloseEditor( activeEditor ) ) {
459
      return false;
460
    }
461
462
    // This should be called any time a tab changes.
463
    persistPreferences();
464
465
    // save modified tabs
466
    for( int i = 0; i < allEditors.length; i++ ) {
467
      final FileEditorTab fileEditor = allEditors[ i ];
468
469
      if( fileEditor == activeEditor ) {
470
        continue;
471
      }
472
473
      if( fileEditor.isModified() ) {
474
        // activate the modified tab to make its modified content visible to
475
        // the user
476
        getSelectionModel().select( i );
477
478
        if( !canCloseEditor( fileEditor ) ) {
479
          return false;
480
        }
481
      }
482
    }
483
484
    // Close all tabs.
485
    for( final FileEditorTab fileEditor : allEditors ) {
486
      if( !closeEditor( fileEditor, false ) ) {
487
        return false;
488
      }
489
    }
490
491
    return getTabs().isEmpty();
492
  }
493
494
  private FileEditorTab[] getAllEditors() {
495
    final ObservableList<Tab> tabs = getTabs();
496
    final int length = tabs.size();
497
    final FileEditorTab[] allEditors = new FileEditorTab[ length ];
498
499
    for( int i = 0; i < length; i++ ) {
500
      allEditors[ i ] = (FileEditorTab) tabs.get( i );
501
    }
502
503
    return allEditors;
504
  }
505
506
  /**
507
   * Returns the file editor tab that has the given path.
508
   *
509
   * @return null No file editor tab for the given path was found.
510
   */
511
  private FileEditorTab findEditor( final Path path ) {
512
    for( final Tab tab : getTabs() ) {
513
      final FileEditorTab fileEditor = (FileEditorTab) tab;
514
515
      if( fileEditor.isPath( path ) ) {
516
        return fileEditor;
517
      }
518
    }
519
520
    return null;
521
  }
522
523
  private FileChooser createFileChooser( String title ) {
524
    final FileChooser fileChooser = new FileChooser();
525
526
    fileChooser.setTitle( title );
527
    fileChooser.getExtensionFilters().addAll(
528
        createExtensionFilters() );
529
530
    final String lastDirectory = getPreferences().get( "lastDirectory", null );
531
    File file = new File( (lastDirectory != null) ? lastDirectory : "." );
532
533
    if( !file.isDirectory() ) {
534
      file = new File( "." );
535
    }
536
537
    fileChooser.setInitialDirectory( file );
538
    return fileChooser;
539
  }
540
541
  private List<ExtensionFilter> createExtensionFilters() {
542
    final List<ExtensionFilter> list = new ArrayList<>();
543
544
    // TODO: Return a list of all properties that match the filter prefix.
545
    // This will allow dynamic filters to be added and removed just by
546
    // updating the properties file.
547
    list.add( createExtensionFilter( ALL ) );
548
    list.add( createExtensionFilter( SOURCE ) );
549
    list.add( createExtensionFilter( DEFINITION ) );
550
    list.add( createExtensionFilter( XML ) );
551
    return list;
552
  }
553
554
  /**
555
   * Returns a filter for file name extensions recognized by the application
556
   * that can be opened by the user.
557
   *
558
   * @param filetype Used to find the globbing pattern for extensions.
559
   * @return A filename filter suitable for use by a FileDialog instance.
560
   */
561
  private ExtensionFilter createExtensionFilter( final FileType filetype ) {
562
    final String tKey = String.format( "%s.title.%s",
563
                                       FILTER_EXTENSION_TITLES,
564
                                       filetype );
565
    final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype );
566
567
    return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) );
568
  }
569
570
  private List<String> getExtensions( final String key ) {
571
    return getSettings().getStringSettingList( key );
572
  }
573
574
  private void saveLastDirectory( final File file ) {
575
    getPreferences().put( "lastDirectory", file.getParent() );
576
  }
577
578
  public void restorePreferences() {
579
    int activeIndex = 0;
580
581
    final Preferences preferences = getPreferences();
582
    final String[] fileNames = Utils.getPrefsStrings( preferences, "file" );
583
    final String activeFileName = preferences.get( "activeFile", null );
584
585
    final ArrayList<File> files = new ArrayList<>( fileNames.length );
586
587
    for( final String fileName : fileNames ) {
588
      final File file = new File( fileName );
589
590
      if( file.exists() ) {
591
        files.add( file );
592
593
        if( fileName.equals( activeFileName ) ) {
594
          activeIndex = files.size() - 1;
595
        }
596
      }
597
    }
598
599
    if( files.isEmpty() ) {
600
      newEditor();
601
    }
602
    else {
603
      openEditors( files, activeIndex );
604
    }
605
  }
606
607
  public void persistPreferences() {
608
    final ObservableList<Tab> allEditors = getTabs();
609
    final List<String> fileNames = new ArrayList<>( allEditors.size() );
610
611
    for( final Tab tab : allEditors ) {
612
      final FileEditorTab fileEditor = (FileEditorTab) tab;
613
      final Path filePath = fileEditor.getPath();
614
615
      if( filePath != null ) {
616
        fileNames.add( filePath.toString() );
617
      }
618
    }
619
620
    final Preferences preferences = getPreferences();
621
    Utils.putPrefsStrings( preferences,
622
                           "file",
623
                           fileNames.toArray( new String[ 0 ] ) );
624
625
    final FileEditorTab activeEditor = getActiveFileEditor();
626
    final Path filePath = activeEditor == null ? null : activeEditor.getPath();
627
628
    if( filePath == null ) {
629
      preferences.remove( "activeFile" );
630
    }
631
    else {
632
      preferences.put( "activeFile", filePath.toString() );
633
    }
634
  }
635
636
  private Settings getSettings() {
637
    return mSettings;
638
  }
639
640
  protected Options getOptions() {
641
    return mOptions;
641642
  }
642643
M src/main/java/com/scrivenvar/Launcher.java
3737
 */
3838
public class Launcher {
39
  /**
40
   * Delegates to the application entry point.
41
   *
42
   * @param args Command-line arguments.
43
   */
3944
  public static void main( final String[] args ) {
4045
    Main.main( args );
M src/main/java/com/scrivenvar/Main.java
6464
  private final MainWindow mMainWindow = new MainWindow();
6565
66
  @SuppressWarnings({"FieldCanBeLocal", "unused"})
6667
  private StageState mStageState;
6768
69
  /**
70
   * Application entry point.
71
   *
72
   * @param args Command-line arguments.
73
   */
6874
  public static void main( final String[] args ) {
6975
    initPreferences();
7076
    launch( args );
71
  }
72
73
  /**
74
   * Sets the factory used for reading user preferences.
75
   */
76
  private static void initPreferences() {
77
    System.setProperty(
78
        "java.util.prefs.PreferencesFactory",
79
        FilePreferencesFactory.class.getName()
80
    );
8177
  }
8278
8379
  /**
84
   * Application entry point.
80
   * JavaFX entry point.
8581
   *
8682
   * @param stage The primary application stage.
...
9591
9692
    stage.show();
93
  }
94
95
  /**
96
   * Sets the factory used for reading user preferences.
97
   */
98
  private static void initPreferences() {
99
    System.setProperty(
100
        "java.util.prefs.PreferencesFactory",
101
        FilePreferencesFactory.class.getName()
102
    );
97103
  }
98104
M src/main/java/com/scrivenvar/MainWindow.java
2828
package com.scrivenvar;
2929
30
import com.scrivenvar.definition.*;
31
import com.scrivenvar.dialogs.RScriptDialog;
32
import com.scrivenvar.editors.EditorPane;
33
import com.scrivenvar.editors.VariableNameInjector;
34
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
35
import com.scrivenvar.predicates.files.FileTypePredicate;
36
import com.scrivenvar.preview.HTMLPreviewPane;
37
import com.scrivenvar.processors.Processor;
38
import com.scrivenvar.processors.ProcessorFactory;
39
import com.scrivenvar.service.Options;
40
import com.scrivenvar.service.Snitch;
41
import com.scrivenvar.service.events.Notifier;
42
import com.scrivenvar.util.Action;
43
import com.scrivenvar.util.ActionUtils;
44
import javafx.application.Platform;
45
import javafx.beans.binding.Bindings;
46
import javafx.beans.binding.BooleanBinding;
47
import javafx.beans.property.BooleanProperty;
48
import javafx.beans.property.SimpleBooleanProperty;
49
import javafx.beans.value.ObservableBooleanValue;
50
import javafx.beans.value.ObservableValue;
51
import javafx.collections.ListChangeListener.Change;
52
import javafx.collections.ObservableList;
53
import javafx.geometry.Pos;
54
import javafx.scene.Node;
55
import javafx.scene.Scene;
56
import javafx.scene.control.*;
57
import javafx.scene.control.Alert.AlertType;
58
import javafx.scene.image.Image;
59
import javafx.scene.image.ImageView;
60
import javafx.scene.input.KeyEvent;
61
import javafx.scene.layout.BorderPane;
62
import javafx.scene.layout.VBox;
63
import javafx.scene.text.Text;
64
import javafx.stage.Window;
65
import javafx.stage.WindowEvent;
66
import org.controlsfx.control.StatusBar;
67
import org.fxmisc.richtext.model.TwoDimensional.Position;
68
69
import java.io.IOException;
70
import java.nio.file.Path;
71
import java.util.*;
72
import java.util.function.Function;
73
import java.util.prefs.Preferences;
74
75
import static com.scrivenvar.Constants.*;
76
import static com.scrivenvar.Messages.get;
77
import static com.scrivenvar.Messages.getLiteral;
78
import static com.scrivenvar.util.StageState.*;
79
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
80
import static javafx.event.Event.fireEvent;
81
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
82
83
/**
84
 * Main window containing a tab pane in the center for file editors.
85
 *
86
 * @author Karl Tauber and White Magic Software, Ltd.
87
 */
88
public class MainWindow implements Observer {
89
90
  private final Options mOptions = Services.load( Options.class );
91
  private final Snitch mSnitch = Services.load( Snitch.class );
92
  private final Notifier mNotifier = Services.load( Notifier.class );
93
94
  private Scene scene;
95
  private StatusBar statusBar;
96
  private Text lineNumberText;
97
  private TextField findTextField;
98
99
  private DefinitionSource definitionSource;
100
  private DefinitionPane definitionPane;
101
  private FileEditorTabPane fileEditorPane;
102
  private HTMLPreviewPane previewPane;
103
104
  /**
105
   * Prevents re-instantiation of processing classes.
106
   */
107
  private Map<FileEditorTab, Processor<String>> processors;
108
109
  /**
110
   * Listens on the definition pane for double-click events.
111
   */
112
  private VariableNameInjector variableNameInjector;
113
114
  public MainWindow() {
115
    initLayout();
116
    initFindInput();
117
    initSnitch();
118
    initDefinitionListener();
119
    initTabAddedListener();
120
    initTabChangedListener();
121
    initPreferences();
122
  }
123
124
  /**
125
   * Watch for changes to external files. In particular, this awaits
126
   * modifications to any XSL files associated with XML files being edited. When
127
   * an XSL file is modified (external to the application), the snitch's ears
128
   * perk up and the file is reloaded. This keeps the XSL transformation up to
129
   * date with what's on the file system.
130
   */
131
  private void initSnitch() {
132
    getSnitch().addObserver( this );
133
  }
134
135
  /**
136
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
137
   * presses.
138
   */
139
  private void initFindInput() {
140
    final TextField input = getFindTextField();
141
142
    input.setOnKeyPressed( ( KeyEvent event ) -> {
143
      switch( event.getCode() ) {
144
        case F3:
145
        case ENTER:
146
          findNext();
147
          break;
148
        case F:
149
          if( !event.isControlDown() ) {
150
            break;
151
          }
152
        case ESCAPE:
153
          getStatusBar().setGraphic( null );
154
          getActiveFileEditor().getEditorPane().requestFocus();
155
          break;
156
      }
157
    } );
158
159
    // Remove when the input field loses focus.
160
    input.focusedProperty().addListener(
161
        (
162
            final ObservableValue<? extends Boolean> focused,
163
            final Boolean oFocus,
164
            final Boolean nFocus ) -> {
165
          if( !nFocus ) {
166
            getStatusBar().setGraphic( null );
167
          }
168
        }
169
    );
170
  }
171
172
  /**
173
   * Listen for file editor tab pane to receive an open definition source event.
174
   */
175
  private void initDefinitionListener() {
176
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
177
        ( ObservableValue<? extends Path> definitionFile,
178
          final Path oldPath, final Path newPath ) -> {
179
          openDefinition( newPath );
180
181
          // Indirectly refresh the resolved map.
182
          setProcessors( null );
183
          updateDefinitionPane();
184
185
          try {
186
            getSnitch().ignore( oldPath );
187
            getSnitch().listen( newPath );
188
          } catch( final IOException ex ) {
189
            error( ex );
190
          }
191
192
          // Will create new processors and therefore a new resolved map.
193
          refreshSelectedTab( getActiveFileEditor() );
194
        }
195
    );
196
  }
197
198
  /**
199
   * When tabs are added, hook the various change listeners onto the new tab so
200
   * that the preview pane refreshes as necessary.
201
   */
202
  private void initTabAddedListener() {
203
    final FileEditorTabPane editorPane = getFileEditorPane();
204
205
    // Make sure the text processor kicks off when new files are opened.
206
    final ObservableList<Tab> tabs = editorPane.getTabs();
207
208
    // Update the preview pane on tab changes.
209
    tabs.addListener(
210
        ( final Change<? extends Tab> change ) -> {
211
          while( change.next() ) {
212
            if( change.wasAdded() ) {
213
              // Multiple tabs can be added simultaneously.
214
              for( final Tab newTab : change.getAddedSubList() ) {
215
                final FileEditorTab tab = (FileEditorTab) newTab;
216
217
                initTextChangeListener( tab );
218
                initCaretParagraphListener( tab );
219
                initKeyboardEventListeners( tab );
220
//              initSyntaxListener( tab );
221
              }
222
            }
223
          }
224
        }
225
    );
226
  }
227
228
  /**
229
   * Reloads the preferences from the previous session.
230
   */
231
  private void initPreferences() {
232
    restoreDefinitionSource();
233
    getFileEditorPane().restorePreferences();
234
    updateDefinitionPane();
235
  }
236
237
  /**
238
   * Listen for new tab selection events.
239
   */
240
  private void initTabChangedListener() {
241
    final FileEditorTabPane editorPane = getFileEditorPane();
242
243
    // Update the preview pane changing tabs.
244
    editorPane.addTabSelectionListener(
245
        ( ObservableValue<? extends Tab> tabPane,
246
          final Tab oldTab, final Tab newTab ) -> {
247
          updateVariableNameInjector();
248
249
          // If there was no old tab, then this is a first time load, which
250
          // can be ignored.
251
          if( oldTab != null ) {
252
            if( newTab == null ) {
253
              closeRemainingTab();
254
            }
255
            else {
256
              // Update the preview with the edited text.
257
              refreshSelectedTab( (FileEditorTab) newTab );
258
            }
259
          }
260
        }
261
    );
262
  }
263
264
  /**
265
   * Ensure that the keyboard events are received when a new tab is added
266
   * to the user interface.
267
   *
268
   * @param tab The tab that can trigger keyboard events, such as control+space.
269
   */
270
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
271
    final VariableNameInjector vin = getVariableNameInjector();
272
    vin.initKeyboardEventListeners( tab );
273
  }
274
275
  private void initTextChangeListener( final FileEditorTab tab ) {
276
    tab.addTextChangeListener(
277
        ( ObservableValue<? extends String> editor,
278
          final String oldValue, final String newValue ) ->
279
            refreshSelectedTab( tab )
280
    );
281
  }
282
283
  private void initCaretParagraphListener( final FileEditorTab tab ) {
284
    tab.addCaretParagraphListener(
285
        ( ObservableValue<? extends Integer> editor,
286
          final Integer oldValue, final Integer newValue ) ->
287
            refreshSelectedTab( tab )
288
    );
289
  }
290
291
  private void updateVariableNameInjector() {
292
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
293
  }
294
295
  private void setVariableNameInjector( final VariableNameInjector injector ) {
296
    this.variableNameInjector = injector;
297
  }
298
299
  private synchronized VariableNameInjector getVariableNameInjector() {
300
    if( this.variableNameInjector == null ) {
301
      final VariableNameInjector vin = createVariableNameInjector();
302
      setVariableNameInjector( vin );
303
    }
304
305
    return this.variableNameInjector;
306
  }
307
308
  private VariableNameInjector createVariableNameInjector() {
309
    final FileEditorTab tab = getActiveFileEditor();
310
    final DefinitionPane pane = getDefinitionPane();
311
312
    return new VariableNameInjector( tab, pane );
313
  }
314
315
  /**
316
   * Called whenever the preview pane becomes out of sync with the file editor
317
   * tab. This can be called when the text changes, the caret paragraph changes,
318
   * or the file tab changes.
319
   *
320
   * @param tab The file editor tab that has been changed in some fashion.
321
   */
322
  private void refreshSelectedTab( final FileEditorTab tab ) {
323
    getPreviewPane().setPath( tab.getPath() );
324
325
    // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
326
    final Position p = tab.getCaretOffset();
327
    getLineNumberText().setText(
328
        get( STATUS_BAR_LINE,
329
             p.getMajor() + 1,
330
             p.getMinor() + 1,
331
             tab.getCaretPosition() + 1
332
        )
333
    );
334
335
    Processor<String> processor = getProcessors().get( tab );
336
337
    if( processor == null ) {
338
      processor = createProcessor( tab );
339
      getProcessors().put( tab, processor );
340
    }
341
342
    try {
343
      getNotifier().clear();
344
      processor.processChain( tab.getEditorText() );
345
    } catch( final Exception ex ) {
346
      error( ex );
347
    }
348
  }
349
350
  /**
351
   * Used to find text in the active file editor window.
352
   */
353
  private void find() {
354
    final TextField input = getFindTextField();
355
    getStatusBar().setGraphic( input );
356
    input.requestFocus();
357
  }
358
359
  public void findNext() {
360
    getActiveFileEditor().searchNext( getFindTextField().getText() );
361
  }
362
363
  /**
364
   * Returns the variable map of interpolated definitions.
365
   *
366
   * @return A map to help dereference variables.
367
   */
368
  private Map<String, String> getResolvedMap() {
369
    return getDefinitionSource().getResolvedMap();
370
  }
371
372
  /**
373
   * Returns the root node for the hierarchical definition source.
374
   *
375
   * @return Data to display in the definition pane.
376
   */
377
  private TreeView<String> getTreeView() {
378
    try {
379
      return getDefinitionSource().asTreeView();
380
    } catch( Exception e ) {
381
      error( e );
382
    }
383
384
    // Slightly redundant as getDefinitionSource() might have returned an
385
    // empty definition source.
386
    return (new EmptyDefinitionSource()).asTreeView();
387
  }
388
389
  /**
390
   * Called when a definition source is opened.
391
   *
392
   * @param path Path to the definition source that was opened.
393
   */
394
  private void openDefinition( final Path path ) {
395
    try {
396
      final DefinitionSource ds = createDefinitionSource( path.toString() );
397
      setDefinitionSource( ds );
398
      storeDefinitionSource();
399
      updateDefinitionPane();
400
    } catch( final Exception e ) {
401
      error( e );
402
    }
403
  }
404
405
  private void updateDefinitionPane() {
406
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
407
  }
408
409
  private void restoreDefinitionSource() {
410
    final Preferences preferences = getPreferences();
411
    final String source = preferences.get( PERSIST_DEFINITION_SOURCE, "" );
412
413
    setDefinitionSource( createDefinitionSource( source ) );
414
  }
415
416
  private void storeDefinitionSource() {
417
    final Preferences preferences = getPreferences();
418
    final DefinitionSource ds = getDefinitionSource();
419
420
    preferences.put( PERSIST_DEFINITION_SOURCE, ds.toString() );
421
  }
422
423
  /**
424
   * Called when the last open tab is closed to clear the preview pane.
425
   */
426
  private void closeRemainingTab() {
427
    getPreviewPane().clear();
428
  }
429
430
  /**
431
   * Called when an exception occurs that warrants the user's attention.
432
   *
433
   * @param e The exception with a message that the user should know about.
434
   */
435
  private void error( final Exception e ) {
436
    getNotifier().notify( e );
437
  }
438
439
  //---- File actions -------------------------------------------------------
440
441
  /**
442
   * Called when an observable instance has changed. This is called by both the
443
   * snitch service and the notify service. The snitch service can be called for
444
   * different file types, including definition sources.
445
   *
446
   * @param observable The observed instance.
447
   * @param value      The noteworthy item.
448
   */
449
  @Override
450
  public void update( final Observable observable, final Object value ) {
451
    if( value != null ) {
452
      if( observable instanceof Snitch && value instanceof Path ) {
453
        final Path path = (Path) value;
454
        final FileTypePredicate predicate
455
            = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
456
457
        // Reload definitions.
458
        if( predicate.test( path.toFile() ) ) {
459
          updateDefinitionSource( path );
460
        }
461
462
        updateSelectedTab();
463
      }
464
      else if( observable instanceof Notifier && value instanceof String ) {
465
        updateStatusBar( (String) value );
466
      }
467
    }
468
  }
469
470
  /**
471
   * Updates the status bar to show the given message.
472
   *
473
   * @param s The message to show in the status bar.
474
   */
475
  private void updateStatusBar( final String s ) {
476
    Platform.runLater(
477
        () -> {
478
          final int index = s.indexOf( '\n' );
479
          final String message = s.substring(
480
              0, index > 0 ? index : s.length() );
481
482
          getStatusBar().setText( message );
483
        }
484
    );
485
  }
486
487
  /**
488
   * Called when a file has been modified.
489
   */
490
  private void updateSelectedTab() {
491
    Platform.runLater(
492
        () -> {
493
          // Brute-force XSLT file reload by re-instantiating all processors.
494
          resetProcessors();
495
          refreshSelectedTab( getActiveFileEditor() );
496
        }
497
    );
498
  }
499
500
  /**
501
   * Reloads the definition source from the given path.
502
   *
503
   * @param path The path containing new definition information.
504
   */
505
  private void updateDefinitionSource( final Path path ) {
506
    Platform.runLater( () -> openDefinition( path ) );
507
  }
508
509
  /**
510
   * After resetting the processors, they will refresh anew to be up-to-date
511
   * with the files (text and definition) currently loaded into the editor.
512
   */
513
  private void resetProcessors() {
514
    getProcessors().clear();
515
  }
516
517
  //---- File actions -------------------------------------------------------
518
  private void fileNew() {
519
    getFileEditorPane().newEditor();
520
  }
521
522
  private void fileOpen() {
523
    getFileEditorPane().openFileDialog();
524
  }
525
526
  private void fileClose() {
527
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
528
  }
529
530
  private void fileCloseAll() {
531
    getFileEditorPane().closeAllEditors();
532
  }
533
534
  private void fileSave() {
535
    getFileEditorPane().saveEditor( getActiveFileEditor() );
536
  }
537
538
  private void fileSaveAs() {
539
    final FileEditorTab editor = getActiveFileEditor();
540
    getFileEditorPane().saveEditorAs( editor );
541
    getProcessors().remove( editor );
542
543
    try {
544
      refreshSelectedTab( editor );
545
    } catch( final Exception ex ) {
546
      getNotifier().notify( ex );
547
    }
548
  }
549
550
  private void fileSaveAll() {
551
    getFileEditorPane().saveAllEditors();
552
  }
553
554
  private void fileExit() {
555
    final Window window = getWindow();
556
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
557
  }
558
559
  //---- R menu actions
560
  private void rScript() {
561
    final String script = getPreferences().get( PERSIST_R_STARTUP, "" );
562
    final RScriptDialog dialog = new RScriptDialog(
563
        getWindow(), "Dialog.r.script.title", script );
564
    final Optional<String> result = dialog.showAndWait();
565
566
    result.ifPresent( this::putStartupScript );
567
  }
568
569
  private void rDirectory() {
570
    final TextInputDialog dialog = new TextInputDialog(
571
        getPreferences().get( PERSIST_R_DIRECTORY, USER_DIRECTORY )
572
    );
573
574
    dialog.setTitle( get( "Dialog.r.directory.title" ) );
575
    dialog.setHeaderText( getLiteral( "Dialog.r.directory.header" ) );
576
    dialog.setContentText( "Directory" );
577
578
    final Optional<String> result = dialog.showAndWait();
579
580
    result.ifPresent( this::putStartupDirectory );
581
  }
582
583
  /**
584
   * Stores the R startup script into the user preferences.
585
   */
586
  private void putStartupScript( final String script ) {
587
    putPreference( PERSIST_R_STARTUP, script );
588
  }
589
590
  /**
591
   * Stores the R bootstrap script directory into the user preferences.
592
   */
593
  private void putStartupDirectory( final String directory ) {
594
    putPreference( PERSIST_R_DIRECTORY, directory );
595
  }
596
597
  //---- Help actions -------------------------------------------------------
598
  private void helpAbout() {
599
    Alert alert = new Alert( AlertType.INFORMATION );
600
    alert.setTitle( get( "Dialog.about.title" ) );
601
    alert.setHeaderText( get( "Dialog.about.header" ) );
602
    alert.setContentText( get( "Dialog.about.content" ) );
603
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
604
    alert.initOwner( getWindow() );
605
606
    alert.showAndWait();
607
  }
608
609
  //---- Convenience accessors ----------------------------------------------
610
  private float getFloat( final String key, final float defaultValue ) {
611
    return getPreferences().getFloat( key, defaultValue );
612
  }
613
614
  private Preferences getPreferences() {
615
    return getOptions().getState();
616
  }
617
618
  protected Scene getScene() {
619
    if( this.scene == null ) {
620
      this.scene = createScene();
621
    }
622
623
    return this.scene;
624
  }
625
626
  public Window getWindow() {
627
    return getScene().getWindow();
628
  }
629
630
  private MarkdownEditorPane getActiveEditor() {
631
    final EditorPane pane = getActiveFileEditor().getEditorPane();
632
633
    return pane instanceof MarkdownEditorPane
634
        ? (MarkdownEditorPane) pane
635
        : null;
636
  }
637
638
  private FileEditorTab getActiveFileEditor() {
639
    return getFileEditorPane().getActiveFileEditor();
640
  }
641
642
  //---- Member accessors ---------------------------------------------------
643
  private void setProcessors(
644
      final Map<FileEditorTab, Processor<String>> map ) {
645
    this.processors = map;
646
  }
647
648
  private Map<FileEditorTab, Processor<String>> getProcessors() {
649
    if( this.processors == null ) {
650
      setProcessors( new HashMap<>() );
651
    }
652
653
    return this.processors;
654
  }
655
656
  private FileEditorTabPane getFileEditorPane() {
657
    if( this.fileEditorPane == null ) {
658
      this.fileEditorPane = createFileEditorPane();
659
    }
660
661
    return this.fileEditorPane;
662
  }
663
664
  private HTMLPreviewPane getPreviewPane() {
665
    if( this.previewPane == null ) {
666
      this.previewPane = createPreviewPane();
667
    }
668
669
    return this.previewPane;
670
  }
671
672
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
673
    this.definitionSource = definitionSource;
674
  }
675
676
  private DefinitionSource getDefinitionSource() {
677
    if( this.definitionSource == null ) {
678
      this.definitionSource = new EmptyDefinitionSource();
679
    }
680
681
    return this.definitionSource;
682
  }
683
684
  private DefinitionPane getDefinitionPane() {
685
    if( this.definitionPane == null ) {
686
      this.definitionPane = createDefinitionPane();
687
    }
688
689
    return this.definitionPane;
690
  }
691
692
  private Options getOptions() {
693
    return mOptions;
694
  }
695
696
  private Snitch getSnitch() {
697
    return mSnitch;
698
  }
699
700
  private Notifier getNotifier() {
701
    return mNotifier;
702
  }
703
704
  private Text getLineNumberText() {
705
    if( this.lineNumberText == null ) {
706
      this.lineNumberText = createLineNumberText();
707
    }
708
709
    return this.lineNumberText;
710
  }
711
712
  private synchronized StatusBar getStatusBar() {
713
    if( this.statusBar == null ) {
714
      this.statusBar = createStatusBar();
715
    }
716
717
    return this.statusBar;
718
  }
719
720
  private TextField getFindTextField() {
721
    if( this.findTextField == null ) {
722
      this.findTextField = createFindTextField();
723
    }
724
725
    return this.findTextField;
726
  }
727
728
  //---- Member creators ----------------------------------------------------
729
730
  /**
731
   * Factory to create processors that are suited to different file types.
732
   *
733
   * @param tab The tab that is subjected to processing.
734
   * @return A processor suited to the file type specified by the tab's path.
735
   */
736
  private Processor<String> createProcessor( final FileEditorTab tab ) {
737
    return createProcessorFactory().createProcessor( tab );
738
  }
739
740
  private ProcessorFactory createProcessorFactory() {
741
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
742
  }
743
744
  private DefinitionSource createDefinitionSource( final String path ) {
745
    DefinitionSource ds;
746
747
    try {
748
      ds = createDefinitionFactory().createDefinitionSource( path );
749
750
      if( ds instanceof FileDefinitionSource ) {
751
        try {
752
          getNotifier().notify( ds.getError() );
753
          getSnitch().listen( ((FileDefinitionSource) ds).getPath() );
754
        } catch( final Exception ex ) {
755
          error( ex );
756
        }
757
      }
758
    } catch( final Exception ex ) {
759
      ds = new EmptyDefinitionSource();
760
      error( ex );
761
    }
762
763
    return ds;
764
  }
765
766
  private TextField createFindTextField() {
767
    return new TextField();
768
  }
769
770
  /**
771
   * Create an editor pane to hold file editor tabs.
772
   *
773
   * @return A new instance, never null.
774
   */
775
  private FileEditorTabPane createFileEditorPane() {
776
    return new FileEditorTabPane();
777
  }
778
779
  private HTMLPreviewPane createPreviewPane() {
780
    return new HTMLPreviewPane();
781
  }
782
783
  private DefinitionPane createDefinitionPane() {
784
    return new DefinitionPane( getTreeView() );
785
  }
786
787
  private DefinitionFactory createDefinitionFactory() {
788
    return new DefinitionFactory();
789
  }
790
791
  private StatusBar createStatusBar() {
792
    return new StatusBar();
793
  }
794
795
  private Scene createScene() {
796
    final SplitPane splitPane = new SplitPane(
797
        getDefinitionPane().getNode(),
798
        getFileEditorPane().getNode(),
799
        getPreviewPane().getNode() );
800
801
    splitPane.setDividerPositions(
802
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
803
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
804
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
805
806
    // See: http://broadlyapplicable.blogspot
807
    // .ca/2015/03/javafx-capture-restorePreferences-splitpane.html
808
    final BorderPane borderPane = new BorderPane();
809
    borderPane.setPrefSize( 1024, 800 );
810
    borderPane.setTop( createMenuBar() );
811
    borderPane.setBottom( getStatusBar() );
812
    borderPane.setCenter( splitPane );
813
814
    final VBox box = new VBox();
815
    box.setAlignment( Pos.BASELINE_CENTER );
816
    box.getChildren().add( getLineNumberText() );
817
    getStatusBar().getRightItems().add( box );
818
819
    return new Scene( borderPane );
820
  }
821
822
  private Text createLineNumberText() {
823
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
824
  }
825
826
  private Node createMenuBar() {
827
    final BooleanBinding activeFileEditorIsNull =
828
        getFileEditorPane().activeFileEditorProperty()
829
                           .isNull();
830
831
    // File actions
832
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ),
833
                                             "Shortcut+N", FILE_ALT,
834
                                             e -> fileNew() );
835
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ),
836
                                              "Shortcut+O", FOLDER_OPEN_ALT,
837
                                              e -> fileOpen() );
838
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ),
839
                                               "Shortcut+W", null,
840
                                               e -> fileClose(),
841
                                               activeFileEditorIsNull );
842
    final Action fileCloseAllAction = new Action( get(
843
        "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(),
844
                                                  activeFileEditorIsNull );
845
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ),
846
                                              "Shortcut+S", FLOPPY_ALT,
847
                                              e -> fileSave(),
848
                                              createActiveBooleanProperty(
849
                                                  FileEditorTab::modifiedProperty )
850
                                                  .not() );
851
    final Action fileSaveAsAction = new Action( Messages.get(
852
        "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(),
853
                                                activeFileEditorIsNull );
854
    final Action fileSaveAllAction = new Action(
855
        get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null,
856
        e -> fileSaveAll(),
857
        Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
858
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ),
859
                                              null,
860
                                              null,
861
                                              e -> fileExit() );
862
863
    // Edit actions
864
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ),
865
                                              "Shortcut+Z", UNDO,
866
                                              e -> getActiveEditor().undo(),
867
                                              createActiveBooleanProperty(
868
                                                  FileEditorTab::canUndoProperty )
869
                                                  .not() );
870
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ),
871
                                              "Shortcut+Y", REPEAT,
872
                                              e -> getActiveEditor().redo(),
873
                                              createActiveBooleanProperty(
874
                                                  FileEditorTab::canRedoProperty )
875
                                                  .not() );
876
    final Action editFindAction = new Action( Messages.get(
877
        "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
878
                                              e -> find(),
879
                                              activeFileEditorIsNull );
880
    final Action editFindNextAction = new Action( Messages.get(
881
        "Main.menu.edit.find.next" ), "F3", null,
882
                                                  e -> findNext(),
883
                                                  activeFileEditorIsNull );
884
885
    // Insert actions
886
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ),
887
                                                "Shortcut+B", BOLD,
888
                                                e -> getActiveEditor().surroundSelection(
889
                                                    "**", "**" ),
890
                                                activeFileEditorIsNull );
891
    final Action insertItalicAction = new Action(
892
        get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
893
        e -> getActiveEditor().surroundSelection( "*", "*" ),
894
        activeFileEditorIsNull );
895
    final Action insertSuperscriptAction = new Action( get(
896
        "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
897
                                                       e -> getActiveEditor().surroundSelection(
898
                                                           "^", "^" ),
899
                                                       activeFileEditorIsNull );
900
    final Action insertSubscriptAction = new Action( get(
901
        "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
902
                                                     e -> getActiveEditor().surroundSelection(
903
                                                         "~", "~" ),
904
                                                     activeFileEditorIsNull );
905
    final Action insertStrikethroughAction = new Action( get(
906
        "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
907
                                                         e -> getActiveEditor().surroundSelection(
908
                                                             "~~", "~~" ),
909
                                                         activeFileEditorIsNull );
910
    final Action insertBlockquoteAction = new Action( get(
911
        "Main.menu.insert.blockquote" ),
912
                                                      "Ctrl+Q",
913
                                                      QUOTE_LEFT,
914
                                                      // not Shortcut+Q
915
                                                      // because of conflict
916
                                                      // on Mac
917
                                                      e -> getActiveEditor().surroundSelection(
918
                                                          "\n\n> ", "" ),
919
                                                      activeFileEditorIsNull );
920
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ),
921
                                                "Shortcut+K", CODE,
922
                                                e -> getActiveEditor().surroundSelection(
923
                                                    "`", "`" ),
924
                                                activeFileEditorIsNull );
925
    final Action insertFencedCodeBlockAction = new Action( get(
926
        "Main.menu.insert.fenced_code_block" ),
927
                                                           "Shortcut+Shift+K",
928
                                                           FILE_CODE_ALT,
929
                                                           e -> getActiveEditor()
930
                                                               .surroundSelection(
931
                                                                   "\n\n```\n",
932
                                                                   "\n```\n\n",
933
                                                                   get(
934
                                                                       "Main.menu.insert.fenced_code_block.prompt" ) ),
935
                                                           activeFileEditorIsNull );
936
937
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ),
938
                                                "Shortcut+L", LINK,
939
                                                e -> getActiveEditor().insertLink(),
940
                                                activeFileEditorIsNull );
941
    final Action insertImageAction = new Action( get( "Main.menu.insert" +
942
                                                          ".image" ),
943
                                                 "Shortcut+G", PICTURE_ALT,
944
                                                 e -> getActiveEditor().insertImage(),
945
                                                 activeFileEditorIsNull );
946
947
    final Action[] headers = new Action[ 6 ];
948
949
    // Insert header actions (H1 ... H6)
950
    for( int i = 1; i <= 6; i++ ) {
951
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
952
      final String markup = String.format( "%n%n%s ", hashes );
953
      final String text = get( "Main.menu.insert.header_" + i );
954
      final String accelerator = "Shortcut+" + i;
955
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
956
957
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
958
                                     e -> getActiveEditor().surroundSelection(
959
                                         markup, "", prompt ),
960
                                     activeFileEditorIsNull );
961
    }
962
963
    final Action insertUnorderedListAction = new Action(
964
        get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
965
        e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
966
        activeFileEditorIsNull );
967
    final Action insertOrderedListAction = new Action(
968
        get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
969
        e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
970
        activeFileEditorIsNull );
971
    final Action insertHorizontalRuleAction = new Action(
972
        get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
973
        e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
974
        activeFileEditorIsNull );
975
976
    // R actions
977
    final Action mRScriptAction = new Action(
978
        get( "Main.menu.r.script" ), null, null, e -> rScript() );
979
980
    final Action mRDirectoryAction = new Action(
981
        get( "Main.menu.r.directory" ), null, null, e -> rDirectory() );
982
983
    // Help actions
984
    final Action helpAboutAction = new Action(
985
        get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
986
987
    //---- MenuBar ----
988
    final Menu fileMenu = ActionUtils.createMenu(
989
        get( "Main.menu.file" ),
990
        fileNewAction,
991
        fileOpenAction,
992
        null,
993
        fileCloseAction,
994
        fileCloseAllAction,
995
        null,
996
        fileSaveAction,
997
        fileSaveAsAction,
998
        fileSaveAllAction,
999
        null,
1000
        fileExitAction );
1001
1002
    final Menu editMenu = ActionUtils.createMenu(
1003
        get( "Main.menu.edit" ),
1004
        editUndoAction,
1005
        editRedoAction,
1006
        editFindAction,
1007
        editFindNextAction );
1008
1009
    final Menu insertMenu = ActionUtils.createMenu(
1010
        get( "Main.menu.insert" ),
1011
        insertBoldAction,
1012
        insertItalicAction,
1013
        insertSuperscriptAction,
1014
        insertSubscriptAction,
1015
        insertStrikethroughAction,
1016
        insertBlockquoteAction,
1017
        insertCodeAction,
1018
        insertFencedCodeBlockAction,
1019
        null,
1020
        insertLinkAction,
1021
        insertImageAction,
1022
        null,
1023
        headers[ 0 ],
1024
        headers[ 1 ],
1025
        headers[ 2 ],
1026
        headers[ 3 ],
1027
        headers[ 4 ],
1028
        headers[ 5 ],
1029
        null,
1030
        insertUnorderedListAction,
1031
        insertOrderedListAction,
1032
        insertHorizontalRuleAction );
1033
1034
    final Menu rMenu = ActionUtils.createMenu(
1035
        get( "Main.menu.r" ),
1036
        mRScriptAction,
1037
        mRDirectoryAction );
1038
1039
    final Menu helpMenu = ActionUtils.createMenu(
1040
        get( "Main.menu.help" ),
1041
        helpAboutAction );
1042
1043
    final MenuBar menuBar = new MenuBar(
1044
        fileMenu,
1045
        editMenu,
1046
        insertMenu,
1047
        rMenu,
1048
        helpMenu );
1049
1050
    //---- ToolBar ----
1051
    final ToolBar toolBar = ActionUtils.createToolBar(
1052
        fileNewAction,
1053
        fileOpenAction,
1054
        fileSaveAction,
1055
        null,
1056
        editUndoAction,
1057
        editRedoAction,
1058
        null,
1059
        insertBoldAction,
1060
        insertItalicAction,
1061
        insertSuperscriptAction,
1062
        insertSubscriptAction,
1063
        insertBlockquoteAction,
1064
        insertCodeAction,
1065
        insertFencedCodeBlockAction,
1066
        null,
1067
        insertLinkAction,
1068
        insertImageAction,
1069
        null,
1070
        headers[ 0 ],
1071
        null,
1072
        insertUnorderedListAction,
1073
        insertOrderedListAction );
1074
1075
    return new VBox( menuBar, toolBar );
1076
  }
1077
1078
  /**
1079
   * Creates a boolean property that is bound to another boolean value of the
1080
   * active editor.
1081
   */
1082
  private BooleanProperty createActiveBooleanProperty(
1083
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1084
1085
    final BooleanProperty b = new SimpleBooleanProperty();
1086
    final FileEditorTab tab = getActiveFileEditor();
1087
1088
    if( tab != null ) {
1089
      b.bind( func.apply( tab ) );
1090
    }
1091
1092
    getFileEditorPane().activeFileEditorProperty().addListener(
1093
        ( observable, oldFileEditor, newFileEditor ) -> {
1094
          b.unbind();
1095
1096
          if( newFileEditor != null ) {
1097
            b.bind( func.apply( newFileEditor ) );
1098
          }
1099
          else {
1100
            b.set( false );
1101
          }
1102
        }
1103
    );
1104
1105
    return b;
1106
  }
1107
1108
  private void initLayout() {
1109
    final Scene appScene = getScene();
1110
1111
    appScene.getStylesheets().add( STYLESHEET_SCENE );
1112
1113
    // TODO: Apply an XML syntax highlighting for XML files.
1114
//    appScene.getStylesheets().add( STYLESHEET_XML );
1115
    appScene.windowProperty().addListener(
1116
        ( observable, oldWindow, newWindow ) -> {
1117
          newWindow.setOnCloseRequest( e -> {
1118
            if( !getFileEditorPane().closeAllEditors() ) {
1119
              e.consume();
1120
            }
1121
          } );
1122
        }
1123
    );
1124
  }
1125
1126
  private void putPreference( final String key, final String value ) {
1127
    try {
1128
      getPreferences().put( key, value );
1129
    } catch( final Exception ex ) {
1130
      getNotifier().notify( ex );
1131
    }
30
import com.scrivenvar.definition.DefinitionFactory;
31
import com.scrivenvar.definition.DefinitionPane;
32
import com.scrivenvar.definition.DefinitionSource;
33
import com.scrivenvar.definition.MapInterpolator;
34
import com.scrivenvar.definition.yaml.YamlDefinitionSource;
35
import com.scrivenvar.dialogs.RScriptDialog;
36
import com.scrivenvar.editors.EditorPane;
37
import com.scrivenvar.editors.VariableNameInjector;
38
import com.scrivenvar.editors.markdown.MarkdownEditorPane;
39
import com.scrivenvar.preview.HTMLPreviewPane;
40
import com.scrivenvar.processors.Processor;
41
import com.scrivenvar.processors.ProcessorFactory;
42
import com.scrivenvar.service.Options;
43
import com.scrivenvar.service.Settings;
44
import com.scrivenvar.service.Snitch;
45
import com.scrivenvar.service.events.Notifier;
46
import com.scrivenvar.util.Action;
47
import com.scrivenvar.util.ActionUtils;
48
import javafx.application.Platform;
49
import javafx.beans.binding.Bindings;
50
import javafx.beans.binding.BooleanBinding;
51
import javafx.beans.property.BooleanProperty;
52
import javafx.beans.property.SimpleBooleanProperty;
53
import javafx.beans.value.ObservableBooleanValue;
54
import javafx.beans.value.ObservableValue;
55
import javafx.collections.ListChangeListener.Change;
56
import javafx.collections.ObservableList;
57
import javafx.event.Event;
58
import javafx.event.EventHandler;
59
import javafx.geometry.Pos;
60
import javafx.scene.Node;
61
import javafx.scene.Scene;
62
import javafx.scene.control.*;
63
import javafx.scene.control.Alert.AlertType;
64
import javafx.scene.image.Image;
65
import javafx.scene.image.ImageView;
66
import javafx.scene.input.KeyEvent;
67
import javafx.scene.layout.BorderPane;
68
import javafx.scene.layout.VBox;
69
import javafx.scene.text.Text;
70
import javafx.stage.Window;
71
import javafx.stage.WindowEvent;
72
import org.controlsfx.control.StatusBar;
73
import org.fxmisc.richtext.model.TwoDimensional.Position;
74
75
import java.io.File;
76
import java.nio.file.Path;
77
import java.util.*;
78
import java.util.function.Function;
79
import java.util.prefs.Preferences;
80
81
import static com.scrivenvar.Constants.*;
82
import static com.scrivenvar.Messages.get;
83
import static com.scrivenvar.Messages.getLiteral;
84
import static com.scrivenvar.util.StageState.*;
85
import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*;
86
import static javafx.event.Event.fireEvent;
87
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
88
89
/**
90
 * Main window containing a tab pane in the center for file editors.
91
 *
92
 * @author Karl Tauber and White Magic Software, Ltd.
93
 */
94
public class MainWindow implements Observer {
95
96
  private final Options mOptions = Services.load( Options.class );
97
  private final Snitch mSnitch = Services.load( Snitch.class );
98
  private final Settings mSettings = Services.load( Settings.class );
99
  private final Notifier mNotifier = Services.load( Notifier.class );
100
101
  private final Scene mScene;
102
  private final StatusBar mStatusBar;
103
  private final Text mLineNumberText;
104
  private final TextField mFindTextField;
105
106
  private DefinitionSource mDefinitionSource = createDefaultDefinitionSource();
107
  private final DefinitionPane mDefinitionPane = new DefinitionPane();
108
  private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane();
109
  private FileEditorTabPane fileEditorPane;
110
111
  /**
112
   * Prevents re-instantiation of processing classes.
113
   */
114
  private final Map<FileEditorTab, Processor<String>> mProcessors =
115
      new HashMap<>();
116
117
  private final Map<String, String> mResolvedMap =
118
      new HashMap<>( DEFAULT_MAP_SIZE );
119
120
  /**
121
   * Listens on the definition pane for double-click events.
122
   */
123
  private VariableNameInjector variableNameInjector;
124
125
  /**
126
   * Called when the definition data is changed.
127
   */
128
  final EventHandler<TreeItem.TreeModificationEvent<Event>> mHandler =
129
      event -> {
130
        exportDefinitions( getDefinitionPath() );
131
        interpolateResolvedMap();
132
        refreshActiveTab();
133
      };
134
135
  public MainWindow() {
136
    mStatusBar = createStatusBar();
137
    mLineNumberText = createLineNumberText();
138
    mFindTextField = createFindTextField();
139
    mScene = createScene();
140
141
    initLayout();
142
    initFindInput();
143
    initSnitch();
144
    initDefinitionListener();
145
    initTabAddedListener();
146
    initTabChangedListener();
147
    initPreferences();
148
  }
149
150
  /**
151
   * Watch for changes to external files. In particular, this awaits
152
   * modifications to any XSL files associated with XML files being edited. When
153
   * an XSL file is modified (external to the application), the snitch's ears
154
   * perk up and the file is reloaded. This keeps the XSL transformation up to
155
   * date with what's on the file system.
156
   */
157
  private void initSnitch() {
158
    getSnitch().addObserver( this );
159
  }
160
161
  /**
162
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
163
   * presses.
164
   */
165
  private void initFindInput() {
166
    final TextField input = getFindTextField();
167
168
    input.setOnKeyPressed( ( KeyEvent event ) -> {
169
      switch( event.getCode() ) {
170
        case F3:
171
        case ENTER:
172
          findNext();
173
          break;
174
        case F:
175
          if( !event.isControlDown() ) {
176
            break;
177
          }
178
        case ESCAPE:
179
          getStatusBar().setGraphic( null );
180
          getActiveFileEditor().getEditorPane().requestFocus();
181
          break;
182
      }
183
    } );
184
185
    // Remove when the input field loses focus.
186
    input.focusedProperty().addListener(
187
        (
188
            final ObservableValue<? extends Boolean> focused,
189
            final Boolean oFocus,
190
            final Boolean nFocus ) -> {
191
          if( !nFocus ) {
192
            getStatusBar().setGraphic( null );
193
          }
194
        }
195
    );
196
  }
197
198
  /**
199
   * Listen for {@link FileEditorTabPane} to receive open definition file event.
200
   */
201
  private void initDefinitionListener() {
202
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
203
        ( final ObservableValue<? extends Path> file,
204
          final Path oldPath, final Path newPath ) -> {
205
          // Indirectly refresh the resolved map.
206
          resetProcessors();
207
208
          openDefinitions( newPath );
209
210
          // Will create new processors and therefore a new resolved map.
211
          refreshActiveTab();
212
        }
213
    );
214
  }
215
216
  /**
217
   * When tabs are added, hook the various change listeners onto the new tab so
218
   * that the preview pane refreshes as necessary.
219
   */
220
  private void initTabAddedListener() {
221
    final FileEditorTabPane editorPane = getFileEditorPane();
222
223
    // Make sure the text processor kicks off when new files are opened.
224
    final ObservableList<Tab> tabs = editorPane.getTabs();
225
226
    // Update the preview pane on tab changes.
227
    tabs.addListener(
228
        ( final Change<? extends Tab> change ) -> {
229
          while( change.next() ) {
230
            if( change.wasAdded() ) {
231
              // Multiple tabs can be added simultaneously.
232
              for( final Tab newTab : change.getAddedSubList() ) {
233
                final FileEditorTab tab = (FileEditorTab) newTab;
234
235
                initTextChangeListener( tab );
236
                initCaretParagraphListener( tab );
237
                initKeyboardEventListeners( tab );
238
//              initSyntaxListener( tab );
239
              }
240
            }
241
          }
242
        }
243
    );
244
  }
245
246
  /**
247
   * Reloads the preferences from the previous session.
248
   */
249
  private void initPreferences() {
250
    restoreDefinitionPane();
251
    getFileEditorPane().restorePreferences();
252
  }
253
254
  /**
255
   * Listen for new tab selection events.
256
   */
257
  private void initTabChangedListener() {
258
    final FileEditorTabPane editorPane = getFileEditorPane();
259
260
    // Update the preview pane changing tabs.
261
    editorPane.addTabSelectionListener(
262
        ( ObservableValue<? extends Tab> tabPane,
263
          final Tab oldTab, final Tab newTab ) -> {
264
          updateVariableNameInjector();
265
266
          // If there was no old tab, then this is a first time load, which
267
          // can be ignored.
268
          if( oldTab != null ) {
269
            if( newTab == null ) {
270
              closeRemainingTab();
271
            }
272
            else {
273
              // Update the preview with the edited text.
274
              refreshSelectedTab( (FileEditorTab) newTab );
275
            }
276
          }
277
        }
278
    );
279
  }
280
281
  /**
282
   * Ensure that the keyboard events are received when a new tab is added
283
   * to the user interface.
284
   *
285
   * @param tab The tab that can trigger keyboard events, such as control+space.
286
   */
287
  private void initKeyboardEventListeners( final FileEditorTab tab ) {
288
    final VariableNameInjector vin = getVariableNameInjector();
289
    vin.initKeyboardEventListeners( tab );
290
  }
291
292
  private void initTextChangeListener( final FileEditorTab tab ) {
293
    tab.addTextChangeListener(
294
        ( ObservableValue<? extends String> editor,
295
          final String oldValue, final String newValue ) ->
296
            refreshSelectedTab( tab )
297
    );
298
  }
299
300
  private void initCaretParagraphListener( final FileEditorTab tab ) {
301
    tab.addCaretParagraphListener(
302
        ( ObservableValue<? extends Integer> editor,
303
          final Integer oldValue, final Integer newValue ) ->
304
            refreshSelectedTab( tab )
305
    );
306
  }
307
308
  private void updateVariableNameInjector() {
309
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
310
  }
311
312
  private void setVariableNameInjector( final VariableNameInjector injector ) {
313
    this.variableNameInjector = injector;
314
  }
315
316
  private synchronized VariableNameInjector getVariableNameInjector() {
317
    if( this.variableNameInjector == null ) {
318
      final VariableNameInjector vin = createVariableNameInjector();
319
      setVariableNameInjector( vin );
320
    }
321
322
    return this.variableNameInjector;
323
  }
324
325
  private VariableNameInjector createVariableNameInjector() {
326
    final FileEditorTab tab = getActiveFileEditor();
327
    final DefinitionPane pane = getDefinitionPane();
328
329
    return new VariableNameInjector( tab, pane );
330
  }
331
332
  /**
333
   * Called whenever the preview pane becomes out of sync with the file editor
334
   * tab. This can be called when the text changes, the caret paragraph changes,
335
   * or the file tab changes.
336
   *
337
   * @param tab The file editor tab that has been changed in some fashion.
338
   */
339
  private void refreshSelectedTab( final FileEditorTab tab ) {
340
    if( tab == null ) {
341
      return;
342
    }
343
344
    getPreviewPane().setPath( tab.getPath() );
345
346
    // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
347
    final Position p = tab.getCaretOffset();
348
    getLineNumberText().setText(
349
        get( STATUS_BAR_LINE,
350
             p.getMajor() + 1,
351
             p.getMinor() + 1,
352
             tab.getCaretPosition() + 1
353
        )
354
    );
355
356
    Processor<String> processor = getProcessors().get( tab );
357
358
    if( processor == null ) {
359
      processor = createProcessor( tab );
360
      getProcessors().put( tab, processor );
361
    }
362
363
    try {
364
      processor.processChain( tab.getEditorText() );
365
    } catch( final Exception ex ) {
366
      error( ex );
367
    }
368
  }
369
370
  private void refreshActiveTab() {
371
    refreshSelectedTab( getActiveFileEditor() );
372
  }
373
374
  /**
375
   * Used to find text in the active file editor window.
376
   */
377
  private void find() {
378
    final TextField input = getFindTextField();
379
    getStatusBar().setGraphic( input );
380
    input.requestFocus();
381
  }
382
383
  public void findNext() {
384
    getActiveFileEditor().searchNext( getFindTextField().getText() );
385
  }
386
387
  /**
388
   * Returns the variable map of interpolated definitions.
389
   *
390
   * @return A map to help dereference variables.
391
   */
392
  private Map<String, String> getResolvedMap() {
393
    return mResolvedMap;
394
  }
395
396
  private void interpolateResolvedMap() {
397
    final Map<String, String> treeMap = getDefinitionPane().toMap();
398
    final Map<String, String> map = new HashMap<>( treeMap );
399
    MapInterpolator.interpolate( map );
400
401
    getResolvedMap().clear();
402
    getResolvedMap().putAll( map );
403
  }
404
405
  /**
406
   * Called when a definition source is opened.
407
   *
408
   * @param path Path to the definition source that was opened.
409
   */
410
  private void openDefinitions( final Path path ) {
411
    try {
412
      final DefinitionSource ds = createDefinitionSource( path );
413
      setDefinitionSource( ds );
414
      storeDefinitionSourceFilename( path );
415
416
      final DefinitionPane pane = getDefinitionPane();
417
      pane.update( ds );
418
      pane.addTreeChangeHandler( mHandler );
419
420
      interpolateResolvedMap();
421
    } catch( final Exception e ) {
422
      error( e );
423
    }
424
  }
425
426
  private void exportDefinitions( final Path path ) {
427
    try {
428
      final DefinitionPane pane = getDefinitionPane();
429
      final TreeItem<String> root = pane.getTreeView().getRoot();
430
      final TreeItem<String> problemChild = pane.isTreeWellFormed();
431
432
      if( problemChild == null ) {
433
        getDefinitionSource().getTreeAdapter().export( root, path );
434
        getNotifier().clear();
435
      }
436
      else {
437
        final String msg = get( "yaml.error.tree.form",
438
                                problemChild.getValue() );
439
        getNotifier().notify( msg );
440
      }
441
    } catch( final Exception e ) {
442
      error( e );
443
    }
444
  }
445
446
  private Path getDefinitionPath() {
447
    final String source = getPreferences().get(
448
        PERSIST_DEFINITION_SOURCE, "" );
449
450
    return new File(
451
        source.isBlank()
452
            ? getSetting( "file.definition.default", "variables.yaml" )
453
            : source
454
    ).toPath();
455
  }
456
457
  private void restoreDefinitionPane() {
458
    openDefinitions( getDefinitionPath() );
459
  }
460
461
  private void storeDefinitionSourceFilename( final Path path ) {
462
    getPreferences().put( PERSIST_DEFINITION_SOURCE, path.toString() );
463
  }
464
465
  /**
466
   * Called when the last open tab is closed to clear the preview pane.
467
   */
468
  private void closeRemainingTab() {
469
    getPreviewPane().clear();
470
  }
471
472
  /**
473
   * Called when an exception occurs that warrants the user's attention.
474
   *
475
   * @param e The exception with a message that the user should know about.
476
   */
477
  private void error( final Exception e ) {
478
    getNotifier().notify( e );
479
  }
480
481
  //---- File actions -------------------------------------------------------
482
483
  /**
484
   * Called when an observable instance has changed. This is called by both the
485
   * snitch service and the notify service. The snitch service can be called for
486
   * different file types, including definition sources.
487
   *
488
   * @param observable The observed instance.
489
   * @param value      The noteworthy item.
490
   */
491
  @Override
492
  public void update( final Observable observable, final Object value ) {
493
    if( value != null ) {
494
      if( observable instanceof Snitch && value instanceof Path ) {
495
        updateSelectedTab();
496
      }
497
      else if( observable instanceof Notifier && value instanceof String ) {
498
        updateStatusBar( (String) value );
499
      }
500
    }
501
  }
502
503
  /**
504
   * Updates the status bar to show the given message.
505
   *
506
   * @param s The message to show in the status bar.
507
   */
508
  private void updateStatusBar( final String s ) {
509
    Platform.runLater(
510
        () -> {
511
          final int index = s.indexOf( '\n' );
512
          final String message = s.substring(
513
              0, index > 0 ? index : s.length() );
514
515
          getStatusBar().setText( message );
516
        }
517
    );
518
  }
519
520
  /**
521
   * Called when a file has been modified.
522
   */
523
  private void updateSelectedTab() {
524
    Platform.runLater(
525
        () -> {
526
          // Brute-force XSLT file reload by re-instantiating all processors.
527
          resetProcessors();
528
          refreshActiveTab();
529
        }
530
    );
531
  }
532
533
  /**
534
   * After resetting the processors, they will refresh anew to be up-to-date
535
   * with the files (text and definition) currently loaded into the editor.
536
   */
537
  private void resetProcessors() {
538
    getProcessors().clear();
539
  }
540
541
  //---- File actions -------------------------------------------------------
542
  private void fileNew() {
543
    getFileEditorPane().newEditor();
544
  }
545
546
  private void fileOpen() {
547
    getFileEditorPane().openFileDialog();
548
  }
549
550
  private void fileClose() {
551
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
552
  }
553
554
  private void fileCloseAll() {
555
    getFileEditorPane().closeAllEditors();
556
  }
557
558
  private void fileSave() {
559
    getFileEditorPane().saveEditor( getActiveFileEditor() );
560
  }
561
562
  private void fileSaveAs() {
563
    final FileEditorTab editor = getActiveFileEditor();
564
    getFileEditorPane().saveEditorAs( editor );
565
    getProcessors().remove( editor );
566
567
    try {
568
      refreshSelectedTab( editor );
569
    } catch( final Exception ex ) {
570
      getNotifier().notify( ex );
571
    }
572
  }
573
574
  private void fileSaveAll() {
575
    getFileEditorPane().saveAllEditors();
576
  }
577
578
  private void fileExit() {
579
    final Window window = getWindow();
580
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
581
  }
582
583
  //---- R menu actions
584
  private void rScript() {
585
    final String script = getPreferences().get( PERSIST_R_STARTUP, "" );
586
    final RScriptDialog dialog = new RScriptDialog(
587
        getWindow(), "Dialog.r.script.title", script );
588
    final Optional<String> result = dialog.showAndWait();
589
590
    result.ifPresent( this::putStartupScript );
591
  }
592
593
  private void rDirectory() {
594
    final TextInputDialog dialog = new TextInputDialog(
595
        getPreferences().get( PERSIST_R_DIRECTORY, USER_DIRECTORY )
596
    );
597
598
    dialog.setTitle( get( "Dialog.r.directory.title" ) );
599
    dialog.setHeaderText( getLiteral( "Dialog.r.directory.header" ) );
600
    dialog.setContentText( "Directory" );
601
602
    final Optional<String> result = dialog.showAndWait();
603
604
    result.ifPresent( this::putStartupDirectory );
605
  }
606
607
  /**
608
   * Stores the R startup script into the user preferences.
609
   */
610
  private void putStartupScript( final String script ) {
611
    putPreference( PERSIST_R_STARTUP, script );
612
  }
613
614
  /**
615
   * Stores the R bootstrap script directory into the user preferences.
616
   */
617
  private void putStartupDirectory( final String directory ) {
618
    putPreference( PERSIST_R_DIRECTORY, directory );
619
  }
620
621
  //---- Help actions -------------------------------------------------------
622
  private void helpAbout() {
623
    final Alert alert = new Alert( AlertType.INFORMATION );
624
    alert.setTitle( get( "Dialog.about.title" ) );
625
    alert.setHeaderText( get( "Dialog.about.header" ) );
626
    alert.setContentText( get( "Dialog.about.content" ) );
627
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
628
    alert.initOwner( getWindow() );
629
630
    alert.showAndWait();
631
  }
632
633
  //---- Convenience accessors ----------------------------------------------
634
  private float getFloat( final String key, final float defaultValue ) {
635
    return getPreferences().getFloat( key, defaultValue );
636
  }
637
638
  private Preferences getPreferences() {
639
    return getOptions().getState();
640
  }
641
642
  protected Scene getScene() {
643
    return mScene;
644
  }
645
646
  public Window getWindow() {
647
    return getScene().getWindow();
648
  }
649
650
  private MarkdownEditorPane getActiveEditor() {
651
    final EditorPane pane = getActiveFileEditor().getEditorPane();
652
653
    return pane instanceof MarkdownEditorPane
654
        ? (MarkdownEditorPane) pane
655
        : null;
656
  }
657
658
  private FileEditorTab getActiveFileEditor() {
659
    return getFileEditorPane().getActiveFileEditor();
660
  }
661
662
  //---- Member accessors ---------------------------------------------------
663
664
  private Map<FileEditorTab, Processor<String>> getProcessors() {
665
    return mProcessors;
666
  }
667
668
  private FileEditorTabPane getFileEditorPane() {
669
    if( this.fileEditorPane == null ) {
670
      this.fileEditorPane = createFileEditorPane();
671
    }
672
673
    return this.fileEditorPane;
674
  }
675
676
  private HTMLPreviewPane getPreviewPane() {
677
    return mPreviewPane;
678
  }
679
680
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
681
    assert definitionSource != null;
682
    mDefinitionSource = definitionSource;
683
  }
684
685
  private DefinitionSource getDefinitionSource() {
686
    return mDefinitionSource;
687
  }
688
689
  private DefinitionPane getDefinitionPane() {
690
    return mDefinitionPane;
691
  }
692
693
  private Options getOptions() {
694
    return mOptions;
695
  }
696
697
  private Snitch getSnitch() {
698
    return mSnitch;
699
  }
700
701
  private Notifier getNotifier() {
702
    return mNotifier;
703
  }
704
705
  private Text getLineNumberText() {
706
    return mLineNumberText;
707
  }
708
709
  private StatusBar getStatusBar() {
710
    return mStatusBar;
711
  }
712
713
  private TextField getFindTextField() {
714
    return mFindTextField;
715
  }
716
717
  //---- Member creators ----------------------------------------------------
718
719
  /**
720
   * Factory to create processors that are suited to different file types.
721
   *
722
   * @param tab The tab that is subjected to processing.
723
   * @return A processor suited to the file type specified by the tab's path.
724
   */
725
  private Processor<String> createProcessor( final FileEditorTab tab ) {
726
    return createProcessorFactory().createProcessor( tab );
727
  }
728
729
  private ProcessorFactory createProcessorFactory() {
730
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
731
  }
732
733
  private DefinitionSource createDefaultDefinitionSource() {
734
    return new YamlDefinitionSource( getDefinitionPath() );
735
  }
736
737
  private DefinitionSource createDefinitionSource( final Path path ) {
738
    try {
739
      return createDefinitionFactory().createDefinitionSource( path );
740
    } catch( final Exception ex ) {
741
      error( ex );
742
      return createDefaultDefinitionSource();
743
    }
744
  }
745
746
  private TextField createFindTextField() {
747
    return new TextField();
748
  }
749
750
  /**
751
   * Create an editor pane to hold file editor tabs.
752
   *
753
   * @return A new instance, never null.
754
   */
755
  private FileEditorTabPane createFileEditorPane() {
756
    return new FileEditorTabPane();
757
  }
758
759
  private DefinitionFactory createDefinitionFactory() {
760
    return new DefinitionFactory();
761
  }
762
763
  private StatusBar createStatusBar() {
764
    return new StatusBar();
765
  }
766
767
  private Scene createScene() {
768
    final SplitPane splitPane = new SplitPane(
769
        getDefinitionPane().getNode(),
770
        getFileEditorPane().getNode(),
771
        getPreviewPane().getNode() );
772
773
    splitPane.setDividerPositions(
774
        getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
775
        getFloat( K_PANE_SPLIT_EDITOR, .45f ),
776
        getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
777
778
    // See: http://broadlyapplicable.blogspot
779
    // .ca/2015/03/javafx-capture-restorePreferences-splitpane.html
780
    final BorderPane borderPane = new BorderPane();
781
    borderPane.setPrefSize( 1024, 800 );
782
    borderPane.setTop( createMenuBar() );
783
    borderPane.setBottom( getStatusBar() );
784
    borderPane.setCenter( splitPane );
785
786
    final VBox box = new VBox();
787
    box.setAlignment( Pos.BASELINE_CENTER );
788
    box.getChildren().add( getLineNumberText() );
789
    getStatusBar().getRightItems().add( box );
790
791
    return new Scene( borderPane );
792
  }
793
794
  private Text createLineNumberText() {
795
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
796
  }
797
798
  private Node createMenuBar() {
799
    final BooleanBinding activeFileEditorIsNull =
800
        getFileEditorPane().activeFileEditorProperty()
801
                           .isNull();
802
803
    // File actions
804
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ),
805
                                             "Shortcut+N", FILE_ALT,
806
                                             e -> fileNew() );
807
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ),
808
                                              "Shortcut+O", FOLDER_OPEN_ALT,
809
                                              e -> fileOpen() );
810
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ),
811
                                               "Shortcut+W", null,
812
                                               e -> fileClose(),
813
                                               activeFileEditorIsNull );
814
    final Action fileCloseAllAction = new Action( get(
815
        "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(),
816
                                                  activeFileEditorIsNull );
817
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ),
818
                                              "Shortcut+S", FLOPPY_ALT,
819
                                              e -> fileSave(),
820
                                              createActiveBooleanProperty(
821
                                                  FileEditorTab::modifiedProperty )
822
                                                  .not() );
823
    final Action fileSaveAsAction = new Action( Messages.get(
824
        "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(),
825
                                                activeFileEditorIsNull );
826
    final Action fileSaveAllAction = new Action(
827
        get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null,
828
        e -> fileSaveAll(),
829
        Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
830
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ),
831
                                              null,
832
                                              null,
833
                                              e -> fileExit() );
834
835
    // Edit actions
836
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ),
837
                                              "Shortcut+Z", UNDO,
838
                                              e -> getActiveEditor().undo(),
839
                                              createActiveBooleanProperty(
840
                                                  FileEditorTab::canUndoProperty )
841
                                                  .not() );
842
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ),
843
                                              "Shortcut+Y", REPEAT,
844
                                              e -> getActiveEditor().redo(),
845
                                              createActiveBooleanProperty(
846
                                                  FileEditorTab::canRedoProperty )
847
                                                  .not() );
848
    final Action editFindAction = new Action( Messages.get(
849
        "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
850
                                              e -> find(),
851
                                              activeFileEditorIsNull );
852
    final Action editFindNextAction = new Action( Messages.get(
853
        "Main.menu.edit.find.next" ), "F3", null,
854
                                                  e -> findNext(),
855
                                                  activeFileEditorIsNull );
856
857
    // Insert actions
858
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ),
859
                                                "Shortcut+B", BOLD,
860
                                                e -> getActiveEditor().surroundSelection(
861
                                                    "**", "**" ),
862
                                                activeFileEditorIsNull );
863
    final Action insertItalicAction = new Action(
864
        get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
865
        e -> getActiveEditor().surroundSelection( "*", "*" ),
866
        activeFileEditorIsNull );
867
    final Action insertSuperscriptAction = new Action( get(
868
        "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
869
                                                       e -> getActiveEditor().surroundSelection(
870
                                                           "^", "^" ),
871
                                                       activeFileEditorIsNull );
872
    final Action insertSubscriptAction = new Action( get(
873
        "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
874
                                                     e -> getActiveEditor().surroundSelection(
875
                                                         "~", "~" ),
876
                                                     activeFileEditorIsNull );
877
    final Action insertStrikethroughAction = new Action( get(
878
        "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
879
                                                         e -> getActiveEditor().surroundSelection(
880
                                                             "~~", "~~" ),
881
                                                         activeFileEditorIsNull );
882
    final Action insertBlockquoteAction = new Action( get(
883
        "Main.menu.insert.blockquote" ),
884
                                                      "Ctrl+Q",
885
                                                      QUOTE_LEFT,
886
                                                      // not Shortcut+Q
887
                                                      // because of conflict
888
                                                      // on Mac
889
                                                      e -> getActiveEditor().surroundSelection(
890
                                                          "\n\n> ", "" ),
891
                                                      activeFileEditorIsNull );
892
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ),
893
                                                "Shortcut+K", CODE,
894
                                                e -> getActiveEditor().surroundSelection(
895
                                                    "`", "`" ),
896
                                                activeFileEditorIsNull );
897
    final Action insertFencedCodeBlockAction = new Action( get(
898
        "Main.menu.insert.fenced_code_block" ),
899
                                                           "Shortcut+Shift+K",
900
                                                           FILE_CODE_ALT,
901
                                                           e -> getActiveEditor()
902
                                                               .surroundSelection(
903
                                                                   "\n\n```\n",
904
                                                                   "\n```\n\n",
905
                                                                   get(
906
                                                                       "Main.menu.insert.fenced_code_block.prompt" ) ),
907
                                                           activeFileEditorIsNull );
908
909
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ),
910
                                                "Shortcut+L", LINK,
911
                                                e -> getActiveEditor().insertLink(),
912
                                                activeFileEditorIsNull );
913
    final Action insertImageAction = new Action( get( "Main.menu.insert" +
914
                                                          ".image" ),
915
                                                 "Shortcut+G", PICTURE_ALT,
916
                                                 e -> getActiveEditor().insertImage(),
917
                                                 activeFileEditorIsNull );
918
919
    // Number of header actions (H1 ... H3)
920
    final int HEADERS = 3;
921
    final Action[] headers = new Action[ HEADERS ];
922
923
    for( int i = 1; i <= HEADERS; i++ ) {
924
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
925
      final String markup = String.format( "%n%n%s ", hashes );
926
      final String text = get( "Main.menu.insert.header_" + i );
927
      final String accelerator = "Shortcut+" + i;
928
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
929
930
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
931
                                     e -> getActiveEditor().surroundSelection(
932
                                         markup, "", prompt ),
933
                                     activeFileEditorIsNull );
934
    }
935
936
    final Action insertUnorderedListAction = new Action(
937
        get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
938
        e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
939
        activeFileEditorIsNull );
940
    final Action insertOrderedListAction = new Action(
941
        get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
942
        e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
943
        activeFileEditorIsNull );
944
    final Action insertHorizontalRuleAction = new Action(
945
        get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
946
        e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
947
        activeFileEditorIsNull );
948
949
    // R actions
950
    final Action mRScriptAction = new Action(
951
        get( "Main.menu.r.script" ), null, null, e -> rScript() );
952
953
    final Action mRDirectoryAction = new Action(
954
        get( "Main.menu.r.directory" ), null, null, e -> rDirectory() );
955
956
    // Help actions
957
    final Action helpAboutAction = new Action(
958
        get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
959
960
    //---- MenuBar ----
961
    final Menu fileMenu = ActionUtils.createMenu(
962
        get( "Main.menu.file" ),
963
        fileNewAction,
964
        fileOpenAction,
965
        null,
966
        fileCloseAction,
967
        fileCloseAllAction,
968
        null,
969
        fileSaveAction,
970
        fileSaveAsAction,
971
        fileSaveAllAction,
972
        null,
973
        fileExitAction );
974
975
    final Menu editMenu = ActionUtils.createMenu(
976
        get( "Main.menu.edit" ),
977
        editUndoAction,
978
        editRedoAction,
979
        editFindAction,
980
        editFindNextAction );
981
982
    final Menu insertMenu = ActionUtils.createMenu(
983
        get( "Main.menu.insert" ),
984
        insertBoldAction,
985
        insertItalicAction,
986
        insertSuperscriptAction,
987
        insertSubscriptAction,
988
        insertStrikethroughAction,
989
        insertBlockquoteAction,
990
        insertCodeAction,
991
        insertFencedCodeBlockAction,
992
        null,
993
        insertLinkAction,
994
        insertImageAction,
995
        null,
996
        headers[ 0 ],
997
        headers[ 1 ],
998
        headers[ 2 ],
999
        null,
1000
        insertUnorderedListAction,
1001
        insertOrderedListAction,
1002
        insertHorizontalRuleAction );
1003
1004
    final Menu rMenu = ActionUtils.createMenu(
1005
        get( "Main.menu.r" ),
1006
        mRScriptAction,
1007
        mRDirectoryAction );
1008
1009
    final Menu helpMenu = ActionUtils.createMenu(
1010
        get( "Main.menu.help" ),
1011
        helpAboutAction );
1012
1013
    final MenuBar menuBar = new MenuBar(
1014
        fileMenu,
1015
        editMenu,
1016
        insertMenu,
1017
        rMenu,
1018
        helpMenu );
1019
1020
    //---- ToolBar ----
1021
    final ToolBar toolBar = ActionUtils.createToolBar(
1022
        fileNewAction,
1023
        fileOpenAction,
1024
        fileSaveAction,
1025
        null,
1026
        editUndoAction,
1027
        editRedoAction,
1028
        null,
1029
        insertBoldAction,
1030
        insertItalicAction,
1031
        insertSuperscriptAction,
1032
        insertSubscriptAction,
1033
        insertBlockquoteAction,
1034
        insertCodeAction,
1035
        insertFencedCodeBlockAction,
1036
        null,
1037
        insertLinkAction,
1038
        insertImageAction,
1039
        null,
1040
        headers[ 0 ],
1041
        null,
1042
        insertUnorderedListAction,
1043
        insertOrderedListAction );
1044
1045
    return new VBox( menuBar, toolBar );
1046
  }
1047
1048
  /**
1049
   * Creates a boolean property that is bound to another boolean value of the
1050
   * active editor.
1051
   */
1052
  private BooleanProperty createActiveBooleanProperty(
1053
      final Function<FileEditorTab, ObservableBooleanValue> func ) {
1054
1055
    final BooleanProperty b = new SimpleBooleanProperty();
1056
    final FileEditorTab tab = getActiveFileEditor();
1057
1058
    if( tab != null ) {
1059
      b.bind( func.apply( tab ) );
1060
    }
1061
1062
    getFileEditorPane().activeFileEditorProperty().addListener(
1063
        ( observable, oldFileEditor, newFileEditor ) -> {
1064
          b.unbind();
1065
1066
          if( newFileEditor != null ) {
1067
            b.bind( func.apply( newFileEditor ) );
1068
          }
1069
          else {
1070
            b.set( false );
1071
          }
1072
        }
1073
    );
1074
1075
    return b;
1076
  }
1077
1078
  private void initLayout() {
1079
    final Scene appScene = getScene();
1080
1081
    appScene.getStylesheets().add( STYLESHEET_SCENE );
1082
1083
    // TODO: Apply an XML syntax highlighting for XML files.
1084
//    appScene.getStylesheets().add( STYLESHEET_XML );
1085
    appScene.windowProperty().addListener(
1086
        ( observable, oldWindow, newWindow ) ->
1087
            newWindow.setOnCloseRequest(
1088
                e -> {
1089
                  if( !getFileEditorPane().closeAllEditors() ) {
1090
                    e.consume();
1091
                  }
1092
                }
1093
            )
1094
    );
1095
  }
1096
1097
  private void putPreference( final String key, final String value ) {
1098
    try {
1099
      getPreferences().put( key, value );
1100
    } catch( final Exception ex ) {
1101
      getNotifier().notify( ex );
1102
    }
1103
  }
1104
1105
  /**
1106
   * Returns the value for a key from the settings properties file.
1107
   *
1108
   * @param key   Key within the settings properties file to find.
1109
   * @param value Default value to return if the key is not found.
1110
   * @return The value for the given key from the settings file, or the
1111
   * given {@code value} if no key found.
1112
   */
1113
  @SuppressWarnings("SameParameterValue")
1114
  private String getSetting( final String key, final String value ) {
1115
    return mSettings.getSetting( key, value );
11321116
  }
11331117
}
M src/main/java/com/scrivenvar/Messages.java
110110
   * @return The value for the key.
111111
   */
112
  public static String get( String key ) {
112
  public static String get( final String key ) {
113113
    try {
114114
      return resolve( RESOURCE_BUNDLE, RESOURCE_BUNDLE.getString( key ) );
...
130130
   * @return The value for the key.
131131
   */
132
  public static String get( String key, Object... args ) {
132
  public static String get( final String key, final Object... args ) {
133133
    return MessageFormat.format( get( key ), args );
134134
  }
M src/main/java/com/scrivenvar/Services.java
5151
   * @return A class that implements the interface.
5252
   */
53
  @SuppressWarnings("unchecked")
5354
  public static <T> T load( final Class<T> api ) {
54
    @SuppressWarnings("unchecked") final T o = (T) get( api );
55
    final T o = (T) get( api );
5556
5657
    return o == null ? newInstance( api ) : o;
M src/main/java/com/scrivenvar/decorators/RVariableDecorator.java
4242
   *
4343
   * @param variableName The string to decorate.
44
   *
4544
   * @return "`r#" + variableName + "`".
4645
   */
4746
  @Override
48
  public String decorate( final String variableName ) {
47
  public String decorate( String variableName ) {
48
    assert variableName != null;
49
50
    // Delete the $ $ sigils from Markdown variables.
51
    if( variableName.length() > 1 ) {
52
      variableName = variableName.substring( 1, variableName.length() - 1 );
53
    }
54
4955
    return PREFIX +
5056
        "x( v$" +
M src/main/java/com/scrivenvar/decorators/YamlVariableDecorator.java
2828
package com.scrivenvar.decorators;
2929
30
import java.util.regex.Pattern;
31
3032
/**
3133
 * Brackets variable names with dollar symbols.
...
4244
4345
  /**
44
   * Returns the given string with a $ symbol prepended and appended. This is
45
   * not null safe. Do not pass null into this method.
46
   *
47
   * @param variableName The string to decorate.
46
   * Compiled regular expression for matching delimited references.
47
   */
48
  public final static Pattern REGEX_PATTERN = Pattern.compile( REGEX );
49
50
  /**
51
   * Returns the given {@link String} verbatim because variables in YAML
52
   * documents and plain Markdown documents already have the appropriate
53
   * tokenizable syntax wrapped around the text.
4854
   *
49
   * @return '$' + variableName + '$';
55
   * @param variableName Returned verbatim.
5056
   */
5157
  @Override
5258
  public String decorate( final String variableName ) {
53
    return '$' + variableName + '$';
59
    assert variableName != null;
60
    return variableName;
61
  }
62
63
  /**
64
   * Sigilifies the given key.
65
   *
66
   * @param key The key to adorn with YAML variable sigil characters.
67
   * @return The given key bracketed by dollar symbols.
68
   */
69
  public static String entoken( final String key ) {
70
    assert key != null;
71
    return '$' + key + '$';
5472
  }
5573
}
D 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
import javafx.scene.control.TreeView;
31
32
/**
33
 * Implements common behaviour for definition sources.
34
 *
35
 * @author White Magic Software, Ltd.
36
 */
37
public abstract class AbstractDefinitionSource implements DefinitionSource {
38
39
  private TreeView<String> mTreeView;
40
41
  /**
42
   * Returns this definition source as an editable graphical user interface
43
   * component.
44
   *
45
   * @return The TreeView for this definition source.
46
   */
47
  @Override
48
  public TreeView<String> asTreeView() {
49
50
    if( mTreeView == null ) {
51
      mTreeView = createTreeView();
52
      mTreeView.setEditable( true );
53
      mTreeView.setCellFactory(
54
          ( TreeView<String> t ) -> new TextFieldTreeCell()
55
      );
56
    }
57
58
    return mTreeView;
59
  }
60
61
  /**
62
   * Creates a newly instantiated tree view ready for adding to the definition
63
   * pane.
64
   *
65
   * @return A new tree view instance, never null.
66
   */
67
  protected abstract TreeView<String> createTreeView();
68
69
  /**
70
   * Ensures that when preferences are saved that an
71
   * {@link EmptyDefinitionSource} does not get saved literally as its
72
   * memory reference (the default value returned by {@link Object#toString()}).
73
   *
74
   * @return Empty string.
75
   */
76
  @Override
77
  public String toString() {
78
    return "";
79
  }
80
}
811
M src/main/java/com/scrivenvar/definition/DefinitionFactory.java
3030
import com.scrivenvar.AbstractFileFactory;
3131
import com.scrivenvar.FileType;
32
import com.scrivenvar.definition.yaml.YamlFileDefinitionSource;
32
import com.scrivenvar.definition.yaml.YamlDefinitionSource;
3333
3434
import java.io.File;
3535
import java.net.URI;
3636
import java.net.URL;
3737
import java.nio.file.Path;
38
import java.nio.file.Paths;
3938
4039
import static com.scrivenvar.Constants.*;
...
6362
   * @return The definition source appropriate for the given path.
6463
   */
65
  public DefinitionSource createDefinitionSource( final String path ) {
66
    final String protocol = getProtocol( path );
64
  public DefinitionSource createDefinitionSource( final Path path ) {
65
    assert path != null;
66
67
    final String protocol = getProtocol( path.toString() );
6768
    DefinitionSource result = null;
6869
6970
    if( DEFINITION_PROTOCOL_FILE.equals( protocol ) ) {
70
      final Path file = Paths.get( path );
71
      final FileType filetype = lookup( file, GLOB_PREFIX_DEFINITION );
72
      result = createFileDefinitionSource( filetype, file );
71
      final FileType filetype = lookup( path, GLOB_PREFIX_DEFINITION );
72
      result = createFileDefinitionSource( filetype, path );
7373
    }
7474
    else {
75
      unknownFileType( protocol, path );
75
      unknownFileType( protocol, path.toString() );
7676
    }
7777
...
8888
  private DefinitionSource createFileDefinitionSource(
8989
      final FileType filetype, final Path path ) {
90
    assert filetype != null;
91
    assert path != null;
9092
91
    return filetype == YAML
92
        ? new YamlFileDefinitionSource( path )
93
        : new EmptyDefinitionSource();
93
    if( filetype == YAML ) {
94
      return new YamlDefinitionSource( path );
95
    }
96
97
    throw new IllegalArgumentException( filetype.toString() );
9498
  }
9599
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
2929
3030
import com.scrivenvar.AbstractPane;
31
import com.scrivenvar.predicates.strings.ContainsPredicate;
32
import com.scrivenvar.predicates.strings.StartsPredicate;
33
import com.scrivenvar.predicates.strings.StringPredicate;
34
import javafx.collections.ObservableList;
35
import javafx.event.EventHandler;
36
import javafx.event.EventType;
37
import javafx.scene.Node;
38
import javafx.scene.control.MultipleSelectionModel;
39
import javafx.scene.control.SelectionMode;
40
import javafx.scene.control.TreeItem;
41
import javafx.scene.control.TreeView;
42
import javafx.scene.input.MouseButton;
43
import javafx.scene.input.MouseEvent;
44
45
import java.util.List;
46
47
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR;
48
import static com.scrivenvar.util.Lists.getFirst;
49
import static javafx.scene.input.MouseButton.PRIMARY;
50
import static javafx.scene.input.MouseEvent.MOUSE_CLICKED;
51
52
/**
53
 * Provides a list of variables that can be referenced in the editor.
54
 *
55
 * @author White Magic Software, Ltd.
56
 */
57
public class DefinitionPane extends AbstractPane {
58
59
  /**
60
   * Trimmed off the end of a word to match a variable name.
61
   */
62
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
63
64
  private final TreeView<String> mTreeView;
65
66
  /**
67
   * Constructs a definition pane with a given tree view root.
68
   * See {@link com.scrivenvar.definition.yaml.YamlTreeAdapter#adapt(String)}
69
   * for details.
70
   *
71
   * @param root The root of the variable definition tree.
72
   */
73
  public DefinitionPane( final TreeView<String> root ) {
74
    assert root != null;
75
76
    mTreeView = root;
77
    initTreeView();
78
  }
79
80
  /**
81
   * Allows observers to receive double-click events on the tree view.
82
   *
83
   * @param handler The handler that will receive double-click events.
84
   */
85
  public void addBranchSelectedListener(
86
      final EventHandler<? super MouseEvent> handler ) {
87
    getTreeView().addEventHandler(
88
        MouseEvent.ANY, event -> {
89
          final MouseButton button = event.getButton();
90
          final int clicks = event.getClickCount();
91
          final EventType<? extends MouseEvent> eventType =
92
              event.getEventType();
93
94
          if( PRIMARY.equals( button ) && clicks == 2 ) {
95
            if( MOUSE_CLICKED.equals( eventType ) ) {
96
              handler.handle( event );
97
            }
98
99
            event.consume();
100
          }
101
        } );
102
  }
103
104
  /**
105
   * Changes the root node of the tree view. Swaps the current root node for the
106
   * root node of the given
107
   *
108
   * @param treeView The tree view containing a new root node; if the parameter
109
   *                 is null, the tree is cleared.
110
   */
111
  public void setRoot( final TreeView<String> treeView ) {
112
    getTreeView().setRoot( treeView == null ? null : treeView.getRoot() );
113
  }
114
115
  /**
116
   * Finds a tree item with a value that exactly matches the given word.
117
   *
118
   * @param trunk     The root item containing a list of nodes to search.
119
   * @param predicate Helps determine whether the node value matches the word.
120
   * @return The item that matches the given word, or null if not found.
121
   */
122
  private TreeItem<String> findNode(
123
      final TreeItem<String> trunk,
124
      final StringPredicate predicate ) {
125
    TreeItem<String> result = null;
126
127
    if( trunk != null ) {
128
      final List<TreeItem<String>> branches = trunk.getChildren();
129
130
      for( final TreeItem<String> leaf : branches ) {
131
        if( predicate.test( leaf.getValue() ) ) {
132
          result = leaf;
133
          break;
134
        }
135
      }
136
    }
137
138
    return result;
139
  }
140
141
  /**
142
   * Calls findNode with the EqualsPredicate. See
143
   * {@link #findNode(TreeItem, StringPredicate)} for details.
144
   *
145
   * @return The result from findNode.
146
   */
147
  private TreeItem<String> findStartsNode(
148
      final TreeItem<String> trunk,
149
      final String word ) {
150
    return findNode( trunk, new StartsPredicate( word ) );
151
  }
152
153
  /**
154
   * Calls findNode with the ContainsPredicate. See
155
   * {@link #findNode(TreeItem, StringPredicate)} for details.
156
   *
157
   * @return The result from findNode.
158
   */
159
  private TreeItem<String> findSubstringNode(
160
      final TreeItem<String> trunk,
161
      final String word ) {
162
    return findNode( trunk, new ContainsPredicate( word ) );
163
  }
164
165
  /**
166
   * Finds a node that matches a prefix and suffix specified by the given path
167
   * variable. The prefix must match a valid node value. The suffix refers to
168
   * the start of a string that matches zero or more children of the node
169
   * specified by the prefix. The algorithm has the following cases:
170
   *
171
   * <ol>
172
   * <li>Path is empty, return first child.</li>
173
   * <li>Path contains a complete match, return corresponding node.</li>
174
   * <li>Path contains a partial match, return nearest node.</li>
175
   * <li>Path contains a complete and partial match, return nearest node.</li>
176
   * </ol>
177
   *
178
   * @param word The word typed by the user, which contains dot-separated node
179
   *             names that represent a path within the YAML tree plus a
180
   *             partial variable
181
   *             name match (for a node).
182
   * @return The node value that starts with the suffix portion of the given
183
   * path, never null.
184
   */
185
  public TreeItem<String> findNode( final String word ) {
186
    String path = word;
187
188
    // Current tree item.
189
    TreeItem<String> cItem = getTreeRoot();
190
191
    // Previous tree item.
192
    TreeItem<String> pItem = cItem;
193
194
    int index = path.indexOf( SEPARATOR_CHAR );
195
196
    while( index >= 0 ) {
197
      final String node = path.substring( 0, index );
198
      path = path.substring( index + 1 );
199
200
      if( (cItem = findStartsNode( cItem, node )) == null ) {
201
        break;
202
      }
203
204
      index = path.indexOf( SEPARATOR_CHAR );
205
      pItem = cItem;
206
    }
207
208
    // Find the node that starts with whatever the user typed.
209
    cItem = findStartsNode( pItem, path );
210
211
    // If there was no matching node, then find a substring match.
212
    if( cItem == null ) {
213
      cItem = findSubstringNode( pItem, path );
214
    }
215
216
    // If neither starts with nor substring matched a node, revert to the last
217
    // known valid node.
218
    if( cItem == null ) {
219
      cItem = pItem;
220
    }
221
222
    return sanitize( cItem );
223
  }
224
225
  /**
226
   * Returns the leaf that matches the given value. If the value is terminally
227
   * punctuated, the punctuation is removed if no match was found.
228
   *
229
   * @param value    The value to find, never null.
230
   * @param contains Set to true to perform a substring match if starts with
231
   *                 fails to match.
232
   * @return The leaf that contains the given value, or null if neither the
233
   * original value nor the terminally-trimmed value was found.
234
   */
235
  public VariableTreeItem<String> findLeaf(
236
      final String value,
237
      final boolean contains ) {
238
239
    final VariableTreeItem<String> root = getTreeRoot();
240
    final VariableTreeItem<String> leaf = root.findLeaf( value, contains );
241
242
    return leaf == null
243
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
244
        : leaf;
245
  }
246
247
  /**
248
   * Removes punctuation from the end of a string. The character set includes:
249
   * <code>:;,.!?-/\¡¿</code>.
250
   *
251
   * @param s The string to trim, never null.
252
   * @return The string trimmed of all terminal characters from the end
253
   */
254
  private String rtrimTerminalPunctuation( final String s ) {
255
    final StringBuilder result = new StringBuilder( s.trim() );
256
257
    while( TERMINALS.contains( "" + result.charAt( result.length() - 1 ) ) ) {
258
      result.setLength( result.length() - 1 );
259
    }
260
261
    return result.toString();
262
  }
263
264
  /**
265
   * Returns the tree root if either item or its first child are null.
266
   *
267
   * @param item The item to make null safe.
268
   * @return A non-null TreeItem, possibly the root item (to avoid null).
269
   */
270
  private TreeItem<String> sanitize( final TreeItem<String> item ) {
271
    TreeItem<String> result;
272
273
    if( item == null ) {
274
      result = getTreeRoot();
275
    }
276
    else {
277
      result = item == getTreeRoot()
278
          ? getFirst( item.getChildren() )
279
          : item;
280
    }
281
282
    return result;
283
  }
284
285
  /**
286
   * Expands the node to the root, recursively.
287
   *
288
   * @param <T>  The type of tree item to expand (usually String).
289
   * @param node The node to expand.
290
   */
291
  public <T> void expand( final TreeItem<T> node ) {
292
    if( node != null ) {
293
      expand( node.getParent() );
294
295
      if( !node.isLeaf() ) {
296
        node.setExpanded( true );
297
      }
298
    }
299
  }
300
301
  public void select( final TreeItem<String> item ) {
302
    clearSelection();
303
    selectItem( getTreeView().getRow( item ) );
304
  }
305
306
  private void clearSelection() {
307
    getSelectionModel().clearSelection();
308
  }
309
310
  private void selectItem( final int row ) {
311
    getSelectionModel().select( row );
312
  }
313
314
  /**
315
   * Collapses the tree, recursively.
316
   */
317
  public void collapse() {
318
    collapse( getTreeRoot().getChildren() );
319
  }
320
321
  /**
322
   * Collapses the tree, recursively.
323
   *
324
   * @param <T>   The type of tree item to expand (usually String).
325
   * @param nodes The nodes to collapse.
326
   */
327
  private <T> void collapse( ObservableList<TreeItem<T>> nodes ) {
328
    for( final TreeItem<T> node : nodes ) {
329
      node.setExpanded( false );
330
      collapse( node.getChildren() );
331
    }
332
  }
333
334
  private void initTreeView() {
335
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
336
  }
337
338
  /**
339
   * Returns the root node to the tree view.
340
   *
341
   * @return getTreeView()
342
   */
343
  public Node getNode() {
344
    return getTreeView();
345
  }
346
347
  private MultipleSelectionModel<TreeItem<String>> getSelectionModel() {
348
    return getTreeView().getSelectionModel();
349
  }
350
351
  /**
352
   * Returns the tree view that contains the YAML definition hierarchy.
353
   *
354
   * @return A non-null instance.
355
   */
356
  private TreeView<String> getTreeView() {
357
    return mTreeView;
358
  }
359
360
  /**
361
   * Returns the root of the tree.
362
   *
363
   * @return The first node added to the YAML definition tree, or a new root
364
   * if no first node could be found.
365
   */
366
  private VariableTreeItem<String> getTreeRoot() {
367
    final TreeItem<String> root = getTreeView().getRoot();
368
369
    return root instanceof VariableTreeItem ?
370
        (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" );
31
import javafx.collections.ObservableList;
32
import javafx.event.Event;
33
import javafx.event.EventHandler;
34
import javafx.scene.Node;
35
import javafx.scene.control.*;
36
import javafx.scene.control.cell.TextFieldTreeCell;
37
import javafx.scene.input.KeyEvent;
38
import javafx.util.StringConverter;
39
40
import java.util.LinkedList;
41
import java.util.List;
42
import java.util.Map;
43
44
import static com.scrivenvar.Messages.get;
45
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
46
47
/**
48
 * Provides the user interface that holdsa {@link TreeView}, which
49
 * allows users to interact with key/value pairs loaded from the
50
 * {@link DocumentParser} and adapted using a {@link TreeAdapter}.
51
 *
52
 * @author White Magic Software, Ltd.
53
 */
54
public final class DefinitionPane extends AbstractPane {
55
56
  /**
57
   * Trimmed off the end of a word to match a variable name.
58
   */
59
  private final static String TERMINALS = ":;,.!?-/\\¡¿";
60
61
  /**
62
   * Contains a view of the definitions.
63
   */
64
  private final TreeView<String> mTreeView = new TreeView<>();
65
66
  /**
67
   * Constructs a definition pane with a given tree view root.
68
   */
69
  public DefinitionPane() {
70
    final var treeView = getTreeView();
71
    treeView.setEditable( true );
72
    treeView.setCellFactory( cell -> createTreeCell() );
73
    treeView.setContextMenu( createContextMenu() );
74
    treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter );
75
    treeView.setShowRoot( false );
76
    getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE );
77
  }
78
79
  /**
80
   * Changes the root of the {@link TreeView} to the root of the
81
   * {@link TreeView} from the {@link DefinitionSource}.
82
   *
83
   * @param definitionSource Container for the hierarchy of key/value pairs
84
   *                         to replace the existing hierarchy.
85
   */
86
  public void update( final DefinitionSource definitionSource ) {
87
    assert definitionSource != null;
88
89
    final TreeAdapter treeAdapter = definitionSource.getTreeAdapter();
90
    final TreeItem<String> root = treeAdapter.adapt(
91
        get( "Pane.definition.node.root.title" )
92
    );
93
94
    getTreeView().setRoot( root );
95
  }
96
97
  public Map<String, String> toMap() {
98
    return TreeItemAdapter.toMap( getTreeView().getRoot() );
99
  }
100
101
  /**
102
   * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView}
103
   * is modified. The modifications include: item value changes, item additions,
104
   * and item removals.
105
   * <p>
106
   * Safe to call multiple times; if a handler is already registered, the
107
   * old handler is used.
108
   * </p>
109
   *
110
   * @param handler The handler to call whenever any {@link TreeItem} changes.
111
   */
112
  public void addTreeChangeHandler(
113
      final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) {
114
    final TreeItem<String> root = getTreeView().getRoot();
115
    root.addEventHandler( TreeItem.valueChangedEvent(), handler );
116
    root.addEventHandler( TreeItem.childrenModificationEvent(), handler );
117
  }
118
119
  /**
120
   * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably
121
   * well-formed for export. A tree is considered well-formed if the following
122
   * conditions are met:
123
   *
124
   * <ul>
125
   *   <li>The root node contains at least one child node having a leaf.</li>
126
   *   <li>There are no leaf nodes with sibling leaf nodes.</li>
127
   * </ul>
128
   *
129
   * @return {@code null} if the document is well-formed, otherwise the
130
   * problematic child {@link TreeItem}.
131
   */
132
  public TreeItem<String> isTreeWellFormed() {
133
    final var root = getTreeView().getRoot();
134
135
    for( final var child : root.getChildren() ) {
136
      final var problemChild = isWellFormed( child );
137
138
      if( child.isLeaf() || problemChild != null ) {
139
        return problemChild;
140
      }
141
    }
142
143
    return null;
144
  }
145
146
  /**
147
   * Determines whether the document is well-formed by ensuring that
148
   * child branches do not contain multiple leaves.
149
   *
150
   * @param item The sub-tree to check for well-formedness.
151
   * @return {@code null} when the tree is well-formed, otherwise the
152
   * problematic {@link TreeItem}.
153
   */
154
  private TreeItem<String> isWellFormed( final TreeItem<String> item ) {
155
    int childLeafs = 0;
156
    int childBranches = 0;
157
158
    for( final TreeItem<String> child : item.getChildren() ) {
159
      if( child.isLeaf() ) {
160
        childLeafs++;
161
      }
162
      else {
163
        childBranches++;
164
      }
165
166
      final var problemChild = isWellFormed( child );
167
168
      if( problemChild != null ) {
169
        return problemChild;
170
      }
171
    }
172
173
    return ((childBranches > 0 && childLeafs == 0) ||
174
        (childBranches == 0 && childLeafs <= 1)) ? null : item;
175
  }
176
177
  /**
178
   * Returns the leaf that matches the given value. If the value is terminally
179
   * punctuated, the punctuation is removed if no match was found.
180
   *
181
   * @param value    The value to find, never null.
182
   * @param findMode Defines how to match words.
183
   * @return The leaf that contains the given value, or null if neither the
184
   * original value nor the terminally-trimmed value was found.
185
   */
186
  public VariableTreeItem<String> findLeaf(
187
      final String value, final FindMode findMode ) {
188
    final VariableTreeItem<String> root = getTreeRoot();
189
    final VariableTreeItem<String> leaf = root.findLeaf( value, findMode );
190
191
    return leaf == null
192
        ? root.findLeaf( rtrimTerminalPunctuation( value ) )
193
        : leaf;
194
  }
195
196
  /**
197
   * Removes punctuation from the end of a string.
198
   *
199
   * @param s The string to trim, never null.
200
   * @return The string trimmed of all terminal characters from the end
201
   */
202
  private String rtrimTerminalPunctuation( final String s ) {
203
    assert s != null;
204
    int index = s.length() - 1;
205
206
    while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) {
207
      index--;
208
    }
209
210
    return s.substring( 0, index );
211
  }
212
213
  /**
214
   * Expands the node to the root, recursively.
215
   *
216
   * @param <T>  The type of tree item to expand (usually String).
217
   * @param node The node to expand.
218
   */
219
  public <T> void expand( final TreeItem<T> node ) {
220
    if( node != null ) {
221
      expand( node.getParent() );
222
223
      if( !node.isLeaf() ) {
224
        node.setExpanded( true );
225
      }
226
    }
227
  }
228
229
  public void select( final TreeItem<String> item ) {
230
    getSelectionModel().clearSelection();
231
    getSelectionModel().select( getTreeView().getRow( item ) );
232
  }
233
234
  /**
235
   * Collapses the tree, recursively.
236
   */
237
  public void collapse() {
238
    collapse( getTreeRoot().getChildren() );
239
  }
240
241
  /**
242
   * Collapses the tree, recursively.
243
   *
244
   * @param <T>   The type of tree item to expand (usually String).
245
   * @param nodes The nodes to collapse.
246
   */
247
  private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) {
248
    for( final TreeItem<T> node : nodes ) {
249
      node.setExpanded( false );
250
      collapse( node.getChildren() );
251
    }
252
  }
253
254
  /**
255
   * @return {@code true} when the user is editing a {@link TreeItem}.
256
   */
257
  private boolean isEditingTreeItem() {
258
    return getTreeView().editingItemProperty().getValue() != null;
259
  }
260
261
  /**
262
   * Changes to edit mode for the selected item.
263
   */
264
  private void editSelectedItem() {
265
    getTreeView().edit( getSelectedItem() );
266
  }
267
268
  /**
269
   * Removes all selected items from the {@link TreeView}.
270
   */
271
  private void deleteSelectedItems() {
272
    for( final TreeItem<String> item : getSelectedItems() ) {
273
      final TreeItem<String> parent = item.getParent();
274
275
      if( parent != null ) {
276
        parent.getChildren().remove( item );
277
      }
278
    }
279
  }
280
281
  /**
282
   * Deletes the selected item.
283
   */
284
  private void deleteSelectedItem() {
285
    final TreeItem<String> c = getSelectedItem();
286
    getSiblings( c ).remove( c );
287
  }
288
289
  /**
290
   * Adds a new item under the selected item (or root if nothing is selected).
291
   * There are a few conditions to consider: when adding to the root,
292
   * when adding to a leaf, and when adding to a non-leaf. Items added to the
293
   * root must contain two items: a key and a value.
294
   */
295
  private void addItem() {
296
    final TreeItem<String> value = createTreeItem();
297
    getSelectedItem().getChildren().add( value );
298
    expand( value );
299
    select( value );
300
  }
301
302
  private ContextMenu createContextMenu() {
303
    final ContextMenu menu = new ContextMenu();
304
    final ObservableList<MenuItem> items = menu.getItems();
305
306
    addMenuItem( items, "Definition.menu.create" )
307
        .setOnAction( e -> addItem() );
308
309
    addMenuItem( items, "Definition.menu.rename" )
310
        .setOnAction( e -> editSelectedItem() );
311
312
    addMenuItem( items, "Definition.menu.remove" )
313
        .setOnAction( e -> deleteSelectedItem() );
314
315
    return menu;
316
  }
317
318
  /**
319
   * Executes hot-keys for edits to the definition tree.
320
   *
321
   * @param event Contains the key code of the key that was pressed.
322
   */
323
  private void keyEventFilter( final KeyEvent event ) {
324
    if( !isEditingTreeItem() ) {
325
      switch( event.getCode() ) {
326
        case ENTER:
327
          expand( getSelectedItem() );
328
          event.consume();
329
330
          break;
331
332
        case DELETE:
333
          deleteSelectedItems();
334
          break;
335
336
        case INSERT:
337
          addItem();
338
          break;
339
340
        case R:
341
          if( event.isControlDown() ) {
342
            editSelectedItem();
343
          }
344
345
          break;
346
      }
347
    }
348
  }
349
350
  /**
351
   * Adds a menu item to a list of menu items.
352
   *
353
   * @param items    The list of menu items to append to.
354
   * @param labelKey The resource bundle key name for the menu item's label.
355
   * @return The menu item added to the list of menu items.
356
   */
357
  private MenuItem addMenuItem(
358
      final List<MenuItem> items, final String labelKey ) {
359
    final MenuItem menuItem = createMenuItem( labelKey );
360
    items.add( menuItem );
361
    return menuItem;
362
  }
363
364
  private MenuItem createMenuItem( final String labelKey ) {
365
    return new MenuItem( get( labelKey ) );
366
  }
367
368
  private VariableTreeItem<String> createTreeItem() {
369
    return new VariableTreeItem<>( get( "Definition.menu.add.default" ) );
370
  }
371
372
  private TreeCell<String> createTreeCell() {
373
    return new TextFieldTreeCell<>(
374
        createStringConverter() ) {
375
      @Override
376
      public void commitEdit( final String newValue ) {
377
        super.commitEdit( newValue );
378
        requestFocus();
379
        select( getTreeItem() );
380
      }
381
    };
382
  }
383
384
  private StringConverter<String> createStringConverter() {
385
    return new StringConverter<>() {
386
      @Override
387
      public String toString( final String object ) {
388
        return object == null ? "" : object;
389
      }
390
391
      @Override
392
      public String fromString( final String string ) {
393
        return string == null ? "" : string;
394
      }
395
    };
396
  }
397
398
  /**
399
   * Returns the tree view that contains the definition hierarchy.
400
   *
401
   * @return A non-null instance.
402
   */
403
  public TreeView<String> getTreeView() {
404
    return mTreeView;
405
  }
406
407
  /**
408
   * Returns the root node to the tree view.
409
   *
410
   * @return getTreeView()
411
   */
412
  public Node getNode() {
413
    return getTreeView();
414
  }
415
416
  /**
417
   * Returns the root of the tree.
418
   *
419
   * @return The first node added to the definition tree.
420
   */
421
  private VariableTreeItem<String> getTreeRoot() {
422
    final TreeItem<String> root = getTreeView().getRoot();
423
424
    return root instanceof VariableTreeItem ?
425
        (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" );
426
  }
427
428
  private ObservableList<TreeItem<String>> getSiblings(
429
      final TreeItem<String> item ) {
430
    final TreeItem<String> root = getTreeView().getRoot();
431
    final TreeItem<String> parent =
432
        (item == null || item == root) ? root : item.getParent();
433
434
    return parent.getChildren();
435
  }
436
437
  private MultipleSelectionModel<TreeItem<String>> getSelectionModel() {
438
    return getTreeView().getSelectionModel();
439
  }
440
441
  /**
442
   * Returns a copy of all the selected items.
443
   *
444
   * @return A list, possibly empty, containing all selected items in the
445
   * {@link TreeView}.
446
   */
447
  private List<TreeItem<String>> getSelectedItems() {
448
    return new LinkedList<>( getSelectionModel().getSelectedItems() );
449
  }
450
451
  private TreeItem<String> getSelectedItem() {
452
    final TreeItem<String> item = getSelectionModel().getSelectedItem();
453
    return item == null ? getTreeView().getRoot() : item;
371454
  }
372455
}
M src/main/java/com/scrivenvar/definition/DefinitionSource.java
11
/*
2
 * Copyright 2016 White Magic Software, Ltd.
2
 * Copyright 2020 White Magic Software, Ltd.
33
 *
44
 * All rights reserved.
...
2727
 */
2828
package com.scrivenvar.definition;
29
30
import javafx.scene.control.TreeView;
31
32
import java.util.Map;
3329
3430
/**
35
 * Represents behaviours for reading and writing variable definitions.
36
 *
37
 * @author White Magic Software, Ltd.
31
 * Represents behaviours for reading and writing string definitions. This
32
 * class cannot have any direct hooks into the user interface, as it defines
33
 * entry points into the definition data model loaded into an object
34
 * hierarchy. That hierarchy is converted to a UI model using an adapter
35
 * pattern.
3836
 */
3937
public interface DefinitionSource {
4038
4139
  /**
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.
40
   * Creates an object capable of producing view-based objects from this
41
   * definition source.
4542
   *
4643
   * @return A hierarchical tree suitable for displaying in the definition pane.
47
   */
48
  TreeView<String> asTreeView();
49
50
  /**
51
   * Returns all the strings with their values resolved in a flat hierarchy.
52
   * This copies all the keys and resolved values into a new map.
53
   *
54
   * @return The new map created with all values having been resolved,
55
   * recursively.
5644
   */
57
  Map<String, String> getResolvedMap();
45
  TreeAdapter getTreeAdapter();
5846
5947
  /**
...
6654
    return "";
6755
  }
68
69
  /**
70
   * Must return a re-loadable path to the data source. For a file, this is the
71
   * absolute file path. For a database, this could be the JDBC connection. For
72
   * a web site, this might be the GET URL.
73
   *
74
   * @return A non-null, non-empty string.
75
   */
76
  @Override
77
  String toString();
7856
}
7957
A src/main/java/com/scrivenvar/definition/DocumentParser.java
1
package com.scrivenvar.definition;
2
3
/**
4
 * Responsible for parsing structured document formats.
5
 *
6
 * @param <T> The type of "node" for the document's object model.
7
 */
8
public interface DocumentParser<T> {
9
10
  /**
11
   * Parses a document into a nested object hierarchy. The object returned
12
   * from this call must be the root node in the document tree.
13
   *
14
   * @return The document's root node, which may be empty but never null.
15
   */
16
  T getDocumentRoot();
17
}
118
D 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.util.HashMap;
31
import java.util.Map;
32
import javafx.scene.control.TreeView;
33
34
/**
35
 * Creates a definition source that has no information to load or save.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public class EmptyDefinitionSource extends AbstractDefinitionSource {
40
41
  public EmptyDefinitionSource() {
42
  }
43
44
  @Override
45
  public Map<String, String> getResolvedMap() {
46
    return new HashMap<>();
47
  }
48
49
  @Override
50
  protected TreeView<String> createTreeView() {
51
    return new TreeView<>();
52
  }
53
}
541
D src/main/java/com/scrivenvar/definition/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;
29
30
import java.nio.file.Path;
31
32
/**
33
 * Implements common behaviour for file definition sources.
34
 *
35
 * @author White Magic Software, Ltd.
36
 */
37
public abstract class FileDefinitionSource extends AbstractDefinitionSource {
38
39
  private Path mPath;
40
41
  /**
42
   * Constructs a new file definition source that can read and write data in the
43
   * hierarchical format contained within the file location specified by the
44
   * path.
45
   *
46
   * @param path Must not be null.
47
   */
48
  public FileDefinitionSource( final Path path ) {
49
    setPath( path );
50
  }
51
52
  private void setPath( final Path path ) {
53
    mPath = path;
54
  }
55
56
  public Path getPath() {
57
    return mPath;
58
  }
59
60
  /**
61
   * @return The path represented by this object.
62
   */
63
  @Override
64
  public String toString() {
65
    return getPath().toString();
66
  }
67
}
681
A src/main/java/com/scrivenvar/definition/FindMode.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
 * Used to find variable keys by matching values. The values are matched
32
 * according to the relationships provided in this enumeration.
33
 */
34
public enum FindMode {
35
  CONTAINS,
36
  STARTS_WITH,
37
  LEVENSHTEIN
38
}
39
140
A src/main/java/com/scrivenvar/definition/MapInterpolator.java
1
/*
2
 * Copyright 2020 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.YamlVariableDecorator;
31
32
import java.util.Map;
33
import java.util.regex.Matcher;
34
35
import static com.scrivenvar.decorators.YamlVariableDecorator.REGEX_PATTERN;
36
37
/**
38
 * Responsible for performing string interpolation on key/value pairs stored
39
 * in a map. The values in the map can use a delimited syntax to refer to
40
 * keys in the map.
41
 *
42
 * @author White Magic Software, Ltd.
43
 */
44
public class MapInterpolator {
45
  private final static int GROUP_DELIMITED = 1;
46
47
  /**
48
   * Empty.
49
   */
50
  private MapInterpolator() {
51
  }
52
53
  /**
54
   * Performs string interpolation on the values in the given map. This will
55
   * change any value in the map that contains a variable that matches
56
   * {@link YamlVariableDecorator#REGEX_PATTERN}.
57
   *
58
   * @param map Contains values that represent references to keys.
59
   */
60
  public static void interpolate( final Map<String, String> map ) {
61
    map.replaceAll( ( k, v ) -> resolve( map, v ) );
62
  }
63
64
  /**
65
   * Given a value with zero or more key references, this will resolve all
66
   * the values, recursively. If a key cannot be dereferenced, the value will
67
   * contain the key name.
68
   *
69
   * @param map   Map to search for keys when resolving key references.
70
   * @param value Value containing zero or more key references
71
   * @return The given value with all embedded key references interpolated.
72
   */
73
  private static String resolve(
74
      final Map<String, String> map, String value ) {
75
    final Matcher matcher = REGEX_PATTERN.matcher( value );
76
77
    while( matcher.find() ) {
78
      final String keyName = matcher.group( GROUP_DELIMITED );
79
80
      final String keyValue = resolve(
81
          map, map.getOrDefault( keyName, keyName )
82
      );
83
84
      value = value.replace( keyName, keyValue );
85
    }
86
87
    return value;
88
  }
89
}
190
A src/main/java/com/scrivenvar/definition/RootTreeItem.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 javafx.scene.control.TreeItem;
31
import javafx.scene.control.TreeView;
32
33
/**
34
 * Indicates that this is the top-most {@link TreeItem}. This class allows
35
 * the {@link TreeItemAdapter} to ignore the topmost definition. Such
36
 * contortions are necessary because {@link TreeView} requires a root item
37
 * that isn't part of the user's definition file.
38
 * <p>
39
 * Another approach would be to associate object pairs per {@link TreeItem},
40
 * but that would be a waste of memory since the only "exception" case is
41
 * the root {@link TreeItem}.
42
 * </p>
43
 *
44
 * @param <T> The type of {@link TreeItem} to store in the {@link TreeView}.
45
 * @author White Magic Software, Ltd.
46
 */
47
public class RootTreeItem<T> extends VariableTreeItem<T> {
48
  /**
49
   * Default constructor, calls the superclass, no other behaviour.
50
   *
51
   * @param value The {@link TreeItem} node name to construct the superclass.
52
   * @see TreeItemAdapter#toMap(TreeItem) for details on how this
53
   * class is used.
54
   */
55
  public RootTreeItem( final T value ) {
56
    super( value );
57
  }
58
}
159
D src/main/java/com/scrivenvar/definition/TextFieldTreeCell.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 javafx.event.ActionEvent;
31
import javafx.scene.control.*;
32
import javafx.scene.input.KeyEvent;
33
34
import static com.scrivenvar.Messages.get;
35
36
/**
37
 * Provides behaviour of adding, removing, and editing tree view items.
38
 *
39
 * @author White Magic Software, Ltd.
40
 */
41
public class TextFieldTreeCell extends TreeCell<String> {
42
43
  private TextField textField;
44
  private final ContextMenu editMenu = new ContextMenu();
45
46
  public TextFieldTreeCell() {
47
    initEditMenu();
48
  }
49
50
  private void initEditMenu() {
51
    final MenuItem addItem = createMenuItem( "Definition.menu.add" );
52
    final MenuItem removeItem = createMenuItem( "Definition.menu.remove" );
53
54
    addItem.setOnAction( ( ActionEvent e ) -> {
55
      final VariableTreeItem<String> treeItem = new VariableTreeItem<>(
56
          "Undefined" );
57
      getTreeItem().getChildren().add( treeItem );
58
    } );
59
60
    removeItem.setOnAction( ( ActionEvent e ) -> {
61
      final TreeItem<?> c = getTreeItem();
62
      c.getParent().getChildren().remove( c );
63
    } );
64
65
    getEditMenu().getItems().add( addItem );
66
    getEditMenu().getItems().add( removeItem );
67
  }
68
69
  private ContextMenu getEditMenu() {
70
    return this.editMenu;
71
  }
72
73
  private MenuItem createMenuItem( String label ) {
74
    return new MenuItem( get( label ) );
75
  }
76
77
  @Override
78
  public void startEdit() {
79
    if( getTreeItem().isLeaf() ) {
80
      super.startEdit();
81
82
      final TextField inputField = getTextField();
83
84
      setText( null );
85
      setGraphic( inputField );
86
      inputField.selectAll();
87
      inputField.requestFocus();
88
    }
89
  }
90
91
  @Override
92
  public void cancelEdit() {
93
    super.cancelEdit();
94
95
    setText( getItem() );
96
    setGraphic( getTreeItem().getGraphic() );
97
  }
98
99
  @Override
100
  public void updateItem( String item, boolean empty ) {
101
    super.updateItem( item, empty );
102
103
    if( empty ) {
104
      setText( null );
105
      setGraphic( null );
106
    }
107
    else if( isEditing() ) {
108
      TextField tf = getTextField();
109
      tf.setText( getItemValue() );
110
111
      setText( null );
112
      setGraphic( tf );
113
    }
114
    else {
115
      setText( getItemValue() );
116
      setGraphic( getTreeItem().getGraphic() );
117
118
      if( !getTreeItem().isLeaf() && getTreeItem().getParent() != null ) {
119
        setContextMenu( getEditMenu() );
120
      }
121
    }
122
  }
123
124
  private TextField createTextField() {
125
    final TextField tf = new TextField( getItemValue() );
126
127
    tf.setOnKeyReleased( ( KeyEvent t ) -> {
128
      switch( t.getCode() ) {
129
        case ENTER:
130
          commitEdit( tf.getText() );
131
          break;
132
        case ESCAPE:
133
          cancelEdit();
134
          break;
135
      }
136
    } );
137
138
    return tf;
139
  }
140
141
  /**
142
   * Returns the item's text value.
143
   *
144
   * @return A non-null String, possibly empty.
145
   */
146
  private String getItemValue() {
147
    return getItem() == null ? "" : getItem();
148
  }
149
150
  private TextField getTextField() {
151
    if( this.textField == null ) {
152
      this.textField = createTextField();
153
    }
154
155
    return this.textField;
156
  }
157
}
1581
A src/main/java/com/scrivenvar/definition/TreeAdapter.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 javafx.scene.control.TreeItem;
31
32
import java.io.IOException;
33
import java.nio.file.Path;
34
35
/**
36
 * Responsible for converting an object hierarchy into a {@link TreeItem}
37
 * hierarchy.
38
 *
39
 * @author White Magic Software, Ltd.
40
 */
41
public interface TreeAdapter {
42
  /**
43
   * Adapts the document produced by the given parser into a {@link TreeItem}
44
   * object that can be presented to the user within a GUI.
45
   *
46
   * @param root The default root node name.
47
   * @return The parsed document in a {@link TreeItem} that can be displayed
48
   * in a panel.
49
   */
50
  TreeItem<String> adapt( String root );
51
52
  /**
53
   * Exports the given root node to the given path.
54
   *
55
   * @param root The root node to export.
56
   * @param path Where to persist the data.
57
   * @throws IOException Could not write the data to the given path.
58
   */
59
  void export( TreeItem<String> root, Path path ) throws IOException;
60
}
161
A src/main/java/com/scrivenvar/definition/TreeItemAdapter.java
1
/*
2
 * Copyright 2020 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.fasterxml.jackson.databind.JsonNode;
31
import com.scrivenvar.decorators.YamlVariableDecorator;
32
import com.scrivenvar.preview.HTMLPreviewPane;
33
import javafx.scene.control.TreeItem;
34
import javafx.scene.control.TreeView;
35
36
import java.util.HashMap;
37
import java.util.Iterator;
38
import java.util.Map;
39
import java.util.Stack;
40
41
import static com.scrivenvar.Constants.DEFAULT_MAP_SIZE;
42
43
/**
44
 * Given a {@link TreeItem}, this will generate a flat map with all the
45
 * values in the tree recursively interpolated. The application integrates
46
 * definition files as follows:
47
 * <ol>
48
 *   <li>Load YAML file into {@link JsonNode} hierarchy.</li>
49
 *   <li>Convert JsonNode to a {@link TreeItem} hierarchy.</li>
50
 *   <li>Interpolate {@link TreeItem} hierarchy as a flat map.</li>
51
 *   <li>Substitute flat map variables into document as required.</li>
52
 * </ol>
53
 *
54
 * <p>
55
 * This class is responsible for producing the interpolated flat map. This
56
 * allows dynamic edits of the {@link TreeView} to be displayed in the
57
 * {@link HTMLPreviewPane} without having to reload the definition file.
58
 * Reloading the definition file would work, but has a number of drawbacks.
59
 * </p>
60
 *
61
 * @author White Magic Software, Ltd.
62
 */
63
public class TreeItemAdapter {
64
  /**
65
   * Separates YAML variable nodes (e.g., the dots in {@code $root.node.var$]).
66
   */
67
  public static final String SEPARATOR = ".";
68
69
  /**
70
   * Default buffer length for keys ({@link StringBuilder} has 16 character
71
   * buffer) that should be large enough for most keys to avoid reallocating
72
   * memory to increase the {@link StringBuilder}'s buffer.
73
   */
74
  public static final int DEFAULT_KEY_LENGTH = 64;
75
76
  /**
77
   * In-order traversal of a {@link TreeItem} hierarchy, exposing each item
78
   * as a consecutive list.
79
   */
80
  private static final class TreeIterator
81
      implements Iterator<TreeItem<String>> {
82
    private final Stack<TreeItem<String>> mStack = new Stack<>();
83
84
    public TreeIterator( final TreeItem<String> root ) {
85
      if( root != null ) {
86
        mStack.push( root );
87
      }
88
    }
89
90
    @Override
91
    public boolean hasNext() {
92
      return !mStack.isEmpty();
93
    }
94
95
    @Override
96
    public TreeItem<String> next() {
97
      final TreeItem<String> next = mStack.pop();
98
      next.getChildren().forEach( mStack::push );
99
100
      return next;
101
    }
102
  }
103
104
  private TreeItemAdapter() {
105
  }
106
107
  /**
108
   * Iterate over a given root node (at any level of the tree) and process each
109
   * leaf node into a flat map. Values must be interpolated separately.
110
   */
111
  public static Map<String, String> toMap( final TreeItem<String> root ) {
112
    final Map<String, String> map = new HashMap<>( DEFAULT_MAP_SIZE );
113
    final TreeIterator iterator = new TreeIterator( root );
114
115
    iterator.forEachRemaining( item -> {
116
      if( item.isLeaf() ) {
117
        map.put( toPath( item.getParent() ), item.getValue() );
118
      }
119
    } );
120
121
    return map;
122
  }
123
124
125
  /**
126
   * For a given node, this will ascend the tree to generate a key name
127
   * that is associated with the leaf node's value.
128
   *
129
   * @param node Ascendants represent the key to this node's value.
130
   * @param <T>  Data type that the {@link TreeItem} contains.
131
   * @return The string representation of the node's unique key.
132
   */
133
  public static <T> String toPath( TreeItem<T> node ) {
134
    assert node != null;
135
136
    final StringBuilder key = new StringBuilder( DEFAULT_KEY_LENGTH );
137
    final Stack<TreeItem<T>> stack = new Stack<>();
138
139
    while( node != null && !(node instanceof RootTreeItem) ) {
140
      stack.push( node );
141
      node = node.getParent();
142
    }
143
144
    // Gets set at end of first iteration (to avoid an if condition).
145
    String separator = "";
146
147
    while( !stack.empty() ) {
148
      final T subkey = stack.pop().getValue();
149
      key.append( separator );
150
      key.append( subkey );
151
      separator = SEPARATOR;
152
    }
153
154
    return YamlVariableDecorator.entoken( key.toString() );
155
  }
156
}
1157
M src/main/java/com/scrivenvar/definition/VariableTreeItem.java
2828
package com.scrivenvar.definition;
2929
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;
30
import javafx.scene.control.TreeItem;
31
3432
import java.text.Normalizer;
35
import static java.text.Normalizer.Form.NFD;
36
import java.util.HashMap;
37
import java.util.Map;
3833
import java.util.Stack;
39
import javafx.scene.control.TreeItem;
34
35
import static com.scrivenvar.definition.FindMode.CONTAINS;
36
import static com.scrivenvar.definition.FindMode.STARTS_WITH;
37
import static java.text.Normalizer.Form.NFD;
4038
4139
/**
4240
 * Provides behaviour afforded to variable names and their corresponding value.
4341
 *
44
 * @author White Magic Software, Ltd.
4542
 * @param <T> The type of TreeItem (usually String).
43
 * @author White Magic Software, Ltd.
4644
 */
4745
public class VariableTreeItem<T> extends TreeItem<T> {
48
49
  private final static int DEFAULT_MAP_SIZE = 1000;
50
51
  private final static VariableDecorator VARIABLE_DECORATOR
52
    = new YamlVariableDecorator();
53
54
  /**
55
   * Flattened tree.
56
   */
57
  private Map<String, String> map;
5846
5947
  /**
...
7159
   *
7260
   * @param text The text to match against each leaf in the tree.
73
   *
7461
   * @return The leaf that has a value starting with the given text.
7562
   */
7663
  public VariableTreeItem<T> findLeaf( final String text ) {
77
    return findLeaf( text, false );
64
    return findLeaf( text, STARTS_WITH );
7865
  }
7966
8067
  /**
8168
   * Finds a leaf starting at the current node with text that matches the given
8269
   * value.
83
   *
84
   * @param text The text to match against each leaf in the tree.
85
   * @param contains Set to true to perform a substring match if starts with
86
   * fails.
8770
   *
71
   * @param text     The text to match against each leaf in the tree.
72
   * @param findMode What algorithm is used to match the given text.
8873
   * @return The leaf that has a value starting with the given text.
8974
   */
9075
  public VariableTreeItem<T> findLeaf(
91
    final String text,
92
    final boolean contains ) {
93
76
      final String text, final FindMode findMode ) {
9477
    final Stack<VariableTreeItem<T>> stack = new Stack<>();
9578
    final VariableTreeItem<T> root = this;
9679
9780
    stack.push( root );
9881
99
    boolean found = false;
82
    // Don't try to find keys for blank/empty variable values.
83
    boolean found = text.isBlank();
10084
    VariableTreeItem<T> node = null;
10185
10286
    while( !found && !stack.isEmpty() ) {
10387
      node = stack.pop();
10488
105
      if( contains && node.valueContains( text ) ) {
89
      if( findMode == CONTAINS && node.valueContains( text ) ) {
10690
        found = true;
10791
      }
108
      else if( !contains && node.valueStartsWith( text ) ) {
92
      else if( findMode == STARTS_WITH && node.valueStartsWith( text ) ) {
10993
        found = true;
11094
      }
11195
      else {
11296
        for( final TreeItem<T> child : node.getChildren() ) {
113
          stack.push( (VariableTreeItem<T>)child );
97
          stack.push( (VariableTreeItem<T>) child );
11498
        }
11599
...
124108
  /**
125109
   * Returns the value of the string without diacritic marks.
126
   * 
110
   *
127111
   * @return A non-null, possibly empty string.
128112
   */
129113
  private String getDiacriticlessValue() {
130114
    final String value = getValue().toString();
131
    final String normalized = Normalizer.normalize(value, NFD);
132
    
133
    return normalized.replaceAll("\\p{M}", "");
115
    final String normalized = Normalizer.normalize( value, NFD );
116
117
    return normalized.replaceAll( "\\p{M}", "" );
134118
  }
135119
136120
  /**
137121
   * Returns true if this node is a leaf and its value starts with the given
138122
   * text.
139123
   *
140124
   * @param s The text to compare against the node value.
141
   *
142125
   * @return true Node is a leaf and its value starts with the given value.
143126
   */
...
150133
   *
151134
   * @param s The text to compare against the node value.
152
   *
153135
   * @return true Node is a leaf and its value contains the given value.
154136
   */
...
165147
   */
166148
  public String toPath() {
167
    final Stack<TreeItem<T>> stack = new Stack<>();
168
    TreeItem<T> node = this;
169
170
    while( node.getParent() != null ) {
171
      stack.push( node );
172
      node = node.getParent();
173
    }
174
175
    final StringBuilder sb = new StringBuilder( DEFAULT_MAX_VAR_LENGTH );
176
177
    while( !stack.isEmpty() ) {
178
      node = stack.pop();
179
180
      if( !node.isLeaf() ) {
181
        sb.append( node.getValue() );
182
183
        // This will add a superfluous separator, but instead of peeking at
184
        // the stack all the time, the last separator will be removed outside
185
        // the loop (one operation executed once).
186
        sb.append( SEPARATOR );
187
      }
188
    }
189
190
    // Remove the trailing SEPARATOR.
191
    if( sb.length() > 0 ) {
192
      sb.setLength( sb.length() - 1 );
193
    }
194
195
    return sb.toString();
196
  }
197
198
  /**
199
   * Returns the hierarchy, flattened to key-value pairs.
200
   *
201
   * @return A map of this tree's key-value pairs.
202
   */
203
  public Map<String, String> getMap() {
204
    if( this.map == null ) {
205
      this.map = new HashMap<>( DEFAULT_MAP_SIZE );
206
      populate( this, this.map );
207
    }
208
209
    return this.map;
210
  }
211
212
  private void populate( final TreeItem<T> parent, final Map<String, String> map ) {
213
    for( final TreeItem<T> child : parent.getChildren() ) {
214
      if( child.isLeaf() ) {
215
        final String key = toVariable( ((VariableTreeItem<String>)child).toPath() );
216
        final String value = child.getValue().toString();
217
218
        map.put( key, value );
219
      }
220
      else {
221
        populate( child, map );
222
      }
223
    }
224
  }
225
226
  /**
227
   * Converts the name of the key to a simple variable by enclosing it with
228
   * dollar symbols.
229
   *
230
   * @param key The key name to change to a variable.
231
   *
232
   * @return $key$
233
   */
234
  public String toVariable( final String key ) {
235
    return VARIABLE_DECORATOR.decorate( key );
149
    return TreeItemAdapter.toPath( getParent() );
236150
  }
237151
}
A src/main/java/com/scrivenvar/definition/yaml/YamlDefinitionSource.java
1
/*
2
 * Copyright 2020 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.DefinitionSource;
31
import com.scrivenvar.definition.TreeAdapter;
32
33
import java.nio.file.Path;
34
35
/**
36
 * Represents a definition data source for YAML files.
37
 *
38
 * @author White Magic Software, Ltd.
39
 */
40
public class YamlDefinitionSource implements DefinitionSource {
41
42
  private final YamlTreeAdapter mYamlTreeAdapter;
43
44
  /**
45
   * Constructs a new YAML definition source, populated from the given file.
46
   *
47
   * @param path Path to the YAML definition file.
48
   */
49
  public YamlDefinitionSource( final Path path ) {
50
    assert path != null;
51
52
    mYamlTreeAdapter = new YamlTreeAdapter( path );
53
  }
54
55
  @Override
56
  public TreeAdapter getTreeAdapter() {
57
    return mYamlTreeAdapter;
58
  }
59
60
  @Override
61
  public String getError() {
62
    return getYamlParser().getError();
63
  }
64
65
  private YamlParser getYamlParser() {
66
    return mYamlTreeAdapter.getYamlParser();
67
  }
68
}
169
D 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 com.scrivenvar.definition.FileDefinitionSource;
31
import javafx.scene.control.TreeView;
32
33
import java.io.InputStream;
34
import java.nio.file.Files;
35
import java.nio.file.Path;
36
import java.util.Map;
37
38
import static com.scrivenvar.Messages.get;
39
40
/**
41
 * Represents a definition data source for YAML files.
42
 *
43
 * @author White Magic Software, Ltd.
44
 */
45
public class YamlFileDefinitionSource extends FileDefinitionSource {
46
47
  private YamlTreeAdapter yamlTreeAdapter;
48
  private YamlParser yamlParser;
49
50
  /**
51
   * Constructs a new YAML definition source, populated from the given file.
52
   *
53
   * @param path Path to the YAML definition file.
54
   */
55
  public YamlFileDefinitionSource( final Path path ) {
56
    super( path );
57
    init();
58
  }
59
60
  private void init() {
61
    setYamlParser( createYamlParser() );
62
  }
63
64
  @Override
65
  public Map<String, String> getResolvedMap() {
66
    return getYamlParser().createResolvedMap();
67
  }
68
69
  private YamlTreeAdapter getYamlTreeAdapter() {
70
    if( this.yamlTreeAdapter == null ) {
71
      setYamlTreeAdapter( new YamlTreeAdapter( getYamlParser() ) );
72
    }
73
74
    return this.yamlTreeAdapter;
75
  }
76
77
  private void setYamlTreeAdapter( final YamlTreeAdapter yamlTreeAdapter ) {
78
    this.yamlTreeAdapter = yamlTreeAdapter;
79
  }
80
81
  private YamlParser getYamlParser() {
82
    if( this.yamlParser == null ) {
83
      setYamlParser( createYamlParser() );
84
    }
85
86
    return this.yamlParser;
87
  }
88
89
  private void setYamlParser( final YamlParser yamlParser ) {
90
    this.yamlParser = yamlParser;
91
  }
92
93
  private YamlParser createYamlParser() {
94
    try( final InputStream in = Files.newInputStream( getPath() ) ) {
95
      return new YamlParser( in );
96
    } catch( final Exception ex ) {
97
      throw new RuntimeException( ex );
98
    }
99
  }
100
101
  @Override
102
  protected TreeView<String> createTreeView() {
103
    return getYamlTreeAdapter().adapt(
104
        get( "Pane.definition.node.root.title" )
105
    );
106
  }
107
108
  @Override
109
  public String getError() {
110
    return getYamlParser().getError();
111
  }
112
}
1131
M src/main/java/com/scrivenvar/definition/yaml/YamlParser.java
2828
package com.scrivenvar.definition.yaml;
2929
30
import com.fasterxml.jackson.core.ObjectCodec;
31
import com.fasterxml.jackson.core.io.IOContext;
3230
import com.fasterxml.jackson.databind.JsonNode;
3331
import com.fasterxml.jackson.databind.ObjectMapper;
34
import com.fasterxml.jackson.databind.node.NullNode;
35
import com.fasterxml.jackson.databind.node.ObjectNode;
3632
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 org.yaml.snakeyaml.DumperOptions;
33
import com.scrivenvar.Messages;
34
import com.scrivenvar.definition.DocumentParser;
4135
42
import java.io.IOException;
4336
import java.io.InputStream;
44
import java.io.Writer;
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;
37
import java.nio.file.Files;
38
import java.nio.file.Path;
39
40
import static com.scrivenvar.Constants.STATUS_BAR_OK;
5141
5242
/**
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
 * <p>
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>
43
 * Responsible for reading a YAML document into an object hierarchy.
7844
 *
7945
 * @author White Magic Software, Ltd.
8046
 */
81
public class YamlParser {
47
public class YamlParser implements DocumentParser<JsonNode> {
8248
8349
  /**
84
   * Separates YAML variable nodes (e.g., the dots in
85
   * <code>$root.node.var$</code>).
50
   * Error that occurred while parsing.
8651
   */
87
  public static final String SEPARATOR = ".";
88
  public static final char SEPARATOR_CHAR = SEPARATOR.charAt( 0 );
89
90
  private final static int GROUP_DELIMITED = 1;
91
  private final static int GROUP_REFERENCE = 2;
92
93
  private final static VariableDecorator VARIABLE_DECORATOR
94
      = new YamlVariableDecorator();
95
9652
  private String mError;
97
98
  /**
99
   * Compiled version of DEFAULT_REGEX.
100
   */
101
  private final static Pattern REGEX_PATTERN
102
      = Pattern.compile( YamlVariableDecorator.REGEX );
103
104
  /**
105
   * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values.
106
   */
107
  private final static char SEPARATOR_YAML = '/';
10853
10954
  /**
11055
   * Start of the Universe (the YAML document node that contains all others).
111
   */
112
  private JsonNode documentRoot;
113
114
  /**
115
   * Map of references to dereferenced field values.
116
   */
117
  private Map<String, String> references;
118
119
  public YamlParser( final InputStream in ) throws IOException {
120
    process( in );
121
  }
122
123
  /**
124
   * Returns the given string with all the delimited references swapped with
125
   * their recursively resolved values.
126
   *
127
   * @param text The text to parse with zero or more delimited references to
128
   *             replace.
129
   * @return The substituted value.
130
   */
131
  public String substitute( String text ) {
132
    final Matcher matcher = patternMatch( text );
133
    final Map<String, String> map = getReferences();
134
135
    while( matcher.find() ) {
136
      final String key = matcher.group( GROUP_DELIMITED );
137
      final String value = map.get( key );
138
139
      if( value == null ) {
140
        missing( text );
141
      }
142
      else {
143
        text = text.replace( key, value );
144
      }
145
    }
146
147
    return text;
148
  }
149
150
  /**
151
   * Returns all the strings with their values resolved in a flat hierarchy.
152
   * This copies all the keys and resolved values into a new map.
153
   *
154
   * @return The new map created with all values having been resolved,
155
   * recursively.
156
   */
157
  public Map<String, String> createResolvedMap() {
158
    final Map<String, String> map = new HashMap<>( 1024 );
159
160
    resolve( getDocumentRoot(), "", map );
161
162
    return map;
163
  }
164
165
  /**
166
   * Iterate over a given root node (at any level of the tree) and adapt each
167
   * leaf node.
168
   *
169
   * @param rootNode A JSON node (YAML node) to adapt.
170
   * @param map      Container that associates definitions with values.
171
   */
172
  private void resolve(
173
      final JsonNode rootNode,
174
      final String path,
175
      final Map<String, String> map ) {
176
177
    if( rootNode != null ) {
178
      rootNode.fields().forEachRemaining(
179
          ( Entry<String, JsonNode> leaf ) -> resolve( leaf, path, map )
180
      );
181
    }
182
  }
183
184
  /**
185
   * Recursively adapt each rootNode to a corresponding rootItem.
186
   *
187
   * @param rootNode The node to adapt.
188
   */
189
  private void resolve(
190
      final Entry<String, JsonNode> rootNode,
191
      final String path,
192
      final Map<String, String> map ) {
193
194
    final JsonNode leafNode = rootNode.getValue();
195
    final String key = rootNode.getKey();
196
197
198
    if( leafNode.isValueNode() ) {
199
      final String value;
200
201
      if( leafNode instanceof NullNode ) {
202
        value = "";
203
      }
204
      else {
205
        value = rootNode.getValue().asText();
206
      }
207
208
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
209
    }
210
211
    if( leafNode.isObject() ) {
212
      resolve( leafNode, path + key + SEPARATOR, map );
213
    }
214
  }
215
216
  /**
217
   * Reads the first document from the given stream of YAML data and returns a
218
   * corresponding object that represents the YAML hierarchy. The calling class
219
   * is responsible for closing the stream. Calling classes should use
220
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
221
   *
222
   * @param in The input stream containing YAML content.
223
   * @throws IOException Could not read the stream.
224
   */
225
  private void process( final InputStream in ) throws IOException {
226
    final ObjectNode root = (ObjectNode) getObjectMapper().readTree( in );
227
    setDocumentRoot( root );
228
    process( root );
229
  }
230
231
  /**
232
   * Iterate over a given root node (at any level of the tree) and process each
233
   * leaf node.
234
   *
235
   * @param root A node to process.
236
   */
237
  private void process( final JsonNode root ) {
238
    root.fields().forEachRemaining( this::process );
239
  }
240
241
  /**
242
   * Process the given field, which is a named node. This is where the
243
   * application does the up-front work of mapping references to their fully
244
   * recursively dereferenced values.
245
   *
246
   * @param field The named node.
247
   */
248
  private void process( final Entry<String, JsonNode> field ) {
249
    final JsonNode node = field.getValue();
250
251
    if( node.isObject() ) {
252
      process( node );
253
    }
254
    else {
255
      final JsonNode fieldValue = field.getValue();
256
257
      // Only basic data types can be parsed into variable values. For
258
      // node structures, YAML has a built-in mechanism.
259
      if( fieldValue.isValueNode() ) {
260
        try {
261
          resolve( fieldValue.asText() );
262
        } catch( StackOverflowError e ) {
263
          setError( "Unresolvable: " + node.textValue() + " = " + fieldValue );
264
        }
265
      }
266
    }
267
  }
268
269
  /**
270
   * Inserts the delimited references and field values into the cache. This will
271
   * overwrite existing references.
272
   *
273
   * @param fieldValue YAML field containing zero or more delimited references.
274
   *                   If it contains a delimited reference, the parameter is
275
   *                   modified with the
276
   *                   dereferenced value before it is returned.
277
   * @return fieldValue without delimited references.
278
   */
279
  private String resolve( String fieldValue ) {
280
    final Matcher matcher = patternMatch( fieldValue );
281
282
    while( matcher.find() ) {
283
      final String delimited = matcher.group( GROUP_DELIMITED );
284
      final String reference = matcher.group( GROUP_REFERENCE );
285
      final String dereference = resolve( lookup( reference ) );
286
287
      fieldValue = fieldValue.replace( delimited, dereference );
288
289
      // This will perform some superfluous calls by overwriting existing
290
      // items in the delimited reference map.
291
      put( delimited, dereference );
292
    }
293
294
    return fieldValue;
295
  }
296
297
  /**
298
   * Inserts a key/value pair into the references map. The map retains
299
   * references and dereferenced values found in the YAML. If the reference
300
   * already exists, this will overwrite with a new value.
301
   *
302
   * @param delimited    The variable name.
303
   * @param dereferenced The resolved value.
304
   */
305
  private void put( String delimited, String dereferenced ) {
306
    if( dereferenced.isEmpty() ) {
307
      missing( delimited );
308
    }
309
    else {
310
      getReferences().put( delimited, dereferenced );
311
    }
312
  }
313
314
  /**
315
   * Writes the modified YAML document to standard output.
316
   */
317
  @SuppressWarnings("unused")
318
  private void writeDocument() throws IOException {
319
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
320
  }
321
322
  /**
323
   * Called when a delimited reference is dereferenced to an empty string. This
324
   * should produce a warning for the user.
325
   *
326
   * @param delimited Delimited reference with no derived value.
327
   */
328
  private void missing( final String delimited ) {
329
    setError( MessageFormat.format( "Missing value for '{0}'.", delimited ) );
330
  }
331
332
  /**
333
   * Returns a REGEX_PATTERN matcher for the given text.
334
   *
335
   * @param text The text that contains zero or more instances of a
336
   *             REGEX_PATTERN that can be found using the regular expression.
337
   */
338
  private Matcher patternMatch( String text ) {
339
    return getPattern().matcher( text );
340
  }
341
342
  /**
343
   * Finds the YAML value for a reference.
344
   *
345
   * @param reference References a value in the YAML document.
346
   * @return The dereferenced value.
347
   */
348
  private String lookup( final String reference ) {
349
    return getDocumentRoot().at( asPath( reference ) ).asText();
350
  }
351
352
  /**
353
   * Converts a reference (not delimited) to a path that can be used to find a
354
   * value that should exist inside the YAML document.
355
   *
356
   * @param reference The reference to convert to a YAML document path.
357
   * @return The reference with a leading slash and its separator characters
358
   * converted to slashes.
35956
   */
360
  private String asPath( final String reference ) {
361
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(),
362
                                               SEPARATOR_YAML );
363
  }
57
  private final JsonNode mDocumentRoot;
36458
36559
  /**
366
   * Sets the parent node for the entire YAML document tree.
60
   * Creates a new YamlParser instance that attempts to parse the contents
61
   * of the YAML document given from a path. In the event that the file either
62
   * does not exist or is empty, a fake
36763
   *
368
   * @param documentRoot The parent node.
64
   * @param path Path to a file containing YAML data to parse.
36965
   */
370
  private void setDocumentRoot( final ObjectNode documentRoot ) {
371
    this.documentRoot = documentRoot;
66
  public YamlParser( final Path path ) {
67
    assert path != null;
68
    mDocumentRoot = parse( path );
37269
  }
37370
37471
  /**
37572
   * Returns the parent node for the entire YAML document tree.
376
   *
377
   * @return The parent node.
378
   */
379
  protected JsonNode getDocumentRoot() {
380
    return this.documentRoot;
381
  }
382
383
  /**
384
   * Returns the compiled regular expression REGEX_PATTERN used to match
385
   * delimited references.
38673
   *
387
   * @return A compiled regex for use with the Matcher.
388
   */
389
  private Pattern getPattern() {
390
    return REGEX_PATTERN;
391
  }
392
393
  /**
394
   * @return The list of references mapped to dereferenced values.
74
   * @return The document root, never {@code null}.
39575
   */
396
  private Map<String, String> getReferences() {
397
    if( this.references == null ) {
398
      this.references = createReferences();
399
    }
400
401
    return this.references;
76
  @Override
77
  public JsonNode getDocumentRoot() {
78
    return mDocumentRoot;
40279
  }
40380
40481
  /**
405
   * Subclasses can override this method to insert their own map.
82
   * Parses the given path containing YAML data into an object hierarchy.
40683
   *
407
   * @return An empty HashMap, never null.
84
   * @param path {@link Path} to the YAML resource to parse.
85
   * @return The parsed contents, or an empty object hierarchy.
40886
   */
409
  protected Map<String, String> createReferences() {
410
    return new HashMap<>();
411
  }
412
413
  private final class ResolverYAMLFactory extends YAMLFactory {
414
415
    private static final long serialVersionUID = 1L;
416
417
    @Override
418
    protected YAMLGenerator _createGenerator(
419
        final Writer out, final IOContext ctxt ) throws IOException {
420
421
      return new ResolverYAMLGenerator(
422
          ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec,
423
          out, _version );
424
    }
425
  }
426
427
  private class ResolverYAMLGenerator extends YAMLGenerator {
428
429
    public ResolverYAMLGenerator(
430
        final IOContext ctxt,
431
        final int jsonFeatures,
432
        final int yamlFeatures,
433
        final ObjectCodec codec,
434
        final Writer out,
435
        final DumperOptions.Version version ) throws IOException {
87
  private JsonNode parse( final Path path ) {
88
    try( final InputStream in = Files.newInputStream( path ) ) {
89
      setError( Messages.get( STATUS_BAR_OK ) );
43690
437
      super( ctxt, jsonFeatures, yamlFeatures, codec, out, version );
438
    }
91
      return new ObjectMapper( new YAMLFactory() ).readTree( in );
92
    } catch( final Exception e ) {
93
      setError( Messages.get( "yaml.error.open" ) );
43994
440
    @Override
441
    public void writeString( final String text ) throws IOException {
442
      super.writeString( substitute( text ) );
95
      // Ensure that a document root node exists by relying on the
96
      // default failure condition when processing. This is required
97
      // because the input stream could not be read.
98
      return new ObjectMapper().createObjectNode();
44399
    }
444
  }
445
446
  private YAMLFactory getYAMLFactory() {
447
    return new ResolverYAMLFactory();
448
  }
449
450
  private ObjectMapper getObjectMapper() {
451
    return new ObjectMapper( getYAMLFactory() );
452
  }
453
454
  /**
455
   * Returns the character used to separate YAML paths within delimited
456
   * references. This will return only the first character of the command line
457
   * parameter, if the default is overridden.
458
   *
459
   * @return A period by default.
460
   */
461
  private char getDelimitedSeparator() {
462
    return SEPARATOR.charAt( 0 );
463100
  }
464101
...
473110
   */
474111
  public String getError() {
475
    return mError == null ? "" : mError;
112
    return mError;
476113
  }
477114
}
M src/main/java/com/scrivenvar/definition/yaml/YamlTreeAdapter.java
2929
3030
import com.fasterxml.jackson.databind.JsonNode;
31
import com.fasterxml.jackson.databind.node.ObjectNode;
32
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
33
import com.scrivenvar.definition.RootTreeItem;
34
import com.scrivenvar.definition.TreeAdapter;
3135
import com.scrivenvar.definition.VariableTreeItem;
3236
import javafx.scene.control.TreeItem;
3337
import javafx.scene.control.TreeView;
3438
39
import java.io.IOException;
40
import java.nio.file.Path;
3541
import java.util.Map.Entry;
3642
3743
/**
3844
 * Transforms a JsonNode hierarchy into a tree that can be displayed in a user
39
 * interface.
45
 * interface and vice-versa.
4046
 *
4147
 * @author White Magic Software, Ltd.
4248
 */
43
public class YamlTreeAdapter {
49
public class YamlTreeAdapter implements TreeAdapter {
50
  private final YamlParser mParser;
4451
45
  private YamlParser yamlParser;
52
  /**
53
   * Constructs a new instance that will use the given path to read
54
   * the object hierarchy from a data source.
55
   *
56
   * @param path Path to YAML contents to parse.
57
   */
58
  public YamlTreeAdapter( final Path path ) {
59
    mParser = new YamlParser( path );
60
  }
4661
47
  public YamlTreeAdapter( final YamlParser parser ) {
48
    setYamlParser( parser );
62
  @Override
63
  public void export( final TreeItem<String> treeItem, final Path path )
64
      throws IOException {
65
    final YAMLMapper mapper = new YAMLMapper();
66
    final ObjectNode root = mapper.createObjectNode();
67
68
    // Iterate over the root item's children. The root item is used by the
69
    // application to ensure definitions can always be added to a tree, as
70
    // such it is not meant to be exported, only its children.
71
    for( final TreeItem<String> child : treeItem.getChildren() ) {
72
      export( child, root );
73
    }
74
75
    // Writes as UTF8 by default.
76
    mapper.writeValue( path.toFile(), root );
4977
  }
5078
5179
  /**
52
   * Converts a YAML document to a TreeView based on the document keys. Only the
53
   * first document in the stream is adapted.
80
   * Recursive method to generate an object hierarchy that represents the
81
   * given {@link TreeItem} hierarchy.
5482
   *
55
   * @param name Root TreeItem node name.
56
   * @return A TreeView populated with all the keys in the YAML document.
83
   * @param item The {@link TreeItem} to reproduce as an object hierarchy.
84
   * @param node The {@link ObjectNode} to update to reflect the
85
   *             {@link TreeItem} hierarchy.
5786
   */
58
  public TreeView<String> adapt( final String name ) {
87
  private void export( final TreeItem<String> item, ObjectNode node ) {
88
    final var children = item.getChildren();
89
90
    // If the current item has more than one non-leaf child, it's an
91
    // object node and must become a new nested object.
92
    if( !(children.size() == 1 && children.get( 0 ).isLeaf()) ) {
93
      node = node.putObject( item.getValue() );
94
    }
95
96
    for( final TreeItem<String> child : children ) {
97
      if( child.isLeaf() ) {
98
        node.put( item.getValue(), child.getValue() );
99
      }
100
      else {
101
        export( child, node );
102
      }
103
    }
104
  }
105
106
  /**
107
   * Converts a YAML document to a {@link TreeItem} based on the document
108
   * keys. Only the first document in the stream is adapted.
109
   *
110
   * @param root Root {@link TreeItem} node name.
111
   * @return A {@link TreeItem} populated with all the keys in the YAML
112
   * document.
113
   */
114
  public TreeItem<String> adapt( final String root ) {
59115
    final JsonNode rootNode = getYamlParser().getDocumentRoot();
60
    final TreeItem<String> rootItem = createTreeItem( name );
116
    final TreeItem<String> rootItem = createRootTreeItem( root );
61117
62118
    rootItem.setExpanded( true );
63119
    adapt( rootNode, rootItem );
64
    return new TreeView<>( rootItem );
120
    return rootItem;
65121
  }
66122
...
74130
  private void adapt(
75131
      final JsonNode rootNode, final TreeItem<String> rootItem ) {
76
77132
    rootNode.fields().forEachRemaining(
78133
        ( Entry<String, JsonNode> leaf ) -> adapt( leaf, rootItem )
...
89144
      final Entry<String, JsonNode> rootNode,
90145
      final TreeItem<String> rootItem ) {
91
92146
    final JsonNode leafNode = rootNode.getValue();
93147
    final String key = rootNode.getKey();
...
106160
107161
  /**
108
   * Creates a new tree item that can be added to the tree view.
162
   * Creates a new {@link TreeItem} that can be added to the {@link TreeView}.
109163
   *
110164
   * @param value The node's value.
111
   * @return A new tree item node, never null.
165
   * @return A new {@link TreeItem}, never {@code null}.
112166
   */
113167
  private TreeItem<String> createTreeItem( final String value ) {
114168
    return new VariableTreeItem<>( value );
115169
  }
116170
117
  private YamlParser getYamlParser() {
118
    return this.yamlParser;
171
  /**
172
   * Creates a new {@link TreeItem} that is intended to be the root-level item
173
   * added to the {@link TreeView}. This allows the root item to be
174
   * distinguished from the other items so that reference keys do not include
175
   * "Definition" as part of their name.
176
   *
177
   * @param value The node's value.
178
   * @return A new {@link TreeItem}, never {@code null}.
179
   */
180
  private TreeItem<String> createRootTreeItem( final String value ) {
181
    return new RootTreeItem<>( value );
119182
  }
120183
121
  private void setYamlParser( final YamlParser yamlParser ) {
122
    this.yamlParser = yamlParser;
184
  public YamlParser getYamlParser() {
185
    return mParser;
123186
  }
124187
}
D src/main/java/com/scrivenvar/definition/yaml/resolvers/ResolverYAMLFactory.java
1
/*
2
 * The MIT License
3
 *
4
 * Copyright 2017 White Magic Software, Ltd..
5
 *
6
 * Permission is hereby granted, free of charge, to any person obtaining a copy
7
 * of this software and associated documentation files (the "Software"), to deal
8
 * in the Software without restriction, including without limitation the rights
9
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
 * copies of the Software, and to permit persons to whom the Software is
11
 * furnished to do so, subject to the following conditions:
12
 *
13
 * The above copyright notice and this permission notice shall be included in
14
 * all copies or substantial portions of the Software.
15
 *
16
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
 * THE SOFTWARE.
23
 */
24
package com.scrivenvar.definition.yaml.resolvers;
25
26
import com.fasterxml.jackson.core.io.IOContext;
27
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
28
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
29
import com.scrivenvar.definition.yaml.YamlParser;
30
import java.io.IOException;
31
import java.io.Writer;
32
33
/**
34
 * Responsible for producing YAML generators.
35
 *
36
 * @author White Magic Software, Ltd.
37
 */
38
public final class ResolverYAMLFactory extends YAMLFactory {
39
40
  private static final long serialVersionUID = 1L;
41
42
  private YamlParser yamlParser;
43
44
  public ResolverYAMLFactory( final YamlParser yamlParser ) {
45
    setYamlParser( yamlParser );
46
  }
47
48
  @Override
49
  protected YAMLGenerator _createGenerator(
50
    final Writer out, final IOContext ctxt ) throws IOException {
51
52
    return new ResolverYAMLGenerator(
53
      getYamlParser(),
54
      ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec,
55
      out, _version );
56
  }
57
58
  /**
59
   * Returns the YAML parser used when constructing this instance.
60
   * 
61
   * @return A non-null instance.
62
   */
63
  private YamlParser getYamlParser() {
64
    return this.yamlParser;
65
  }
66
67
  /**
68
   * Sets the YAML parser used when constructing this instance.
69
   * 
70
   * @param yamlParser A non-null instance.
71
   */
72
  private void setYamlParser( final YamlParser yamlParser ) {
73
    this.yamlParser = yamlParser;
74
  }
75
}
761
D src/main/java/com/scrivenvar/definition/yaml/resolvers/ResolverYAMLGenerator.java
1
/*
2
 * Copyright 2020 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.resolvers;
29
30
import com.fasterxml.jackson.core.ObjectCodec;
31
import com.fasterxml.jackson.core.io.IOContext;
32
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
33
import com.scrivenvar.definition.yaml.YamlParser;
34
import org.yaml.snakeyaml.DumperOptions;
35
36
import java.io.IOException;
37
import java.io.Writer;
38
39
/**
40
 * Intercepts the string writing functionality to resolve the definition
41
 * value.
42
 */
43
public class ResolverYAMLGenerator extends YAMLGenerator {
44
45
  private YamlParser yamlParser;
46
47
  public ResolverYAMLGenerator(
48
      final YamlParser yamlParser,
49
      final IOContext ctxt,
50
      final int jsonFeatures,
51
      final int yamlFeatures,
52
      final ObjectCodec codec,
53
      final Writer out,
54
      final DumperOptions.Version version ) throws IOException {
55
    super( ctxt, jsonFeatures, yamlFeatures, codec, out, version );
56
    setYamlParser( yamlParser );
57
  }
58
59
  @Override
60
  public void writeString( final String text ) throws IOException {
61
    final YamlParser parser = getYamlParser();
62
    super.writeString( parser.substitute( text ) );
63
  }
64
65
  private YamlParser getYamlParser() {
66
    return yamlParser;
67
  }
68
69
  private void setYamlParser( final YamlParser yamlParser ) {
70
    this.yamlParser = yamlParser;
71
  }
72
}
731
M src/main/java/com/scrivenvar/editors/VariableNameDecoratorFactory.java
4646
4747
  public static VariableDecorator newInstance( final Path path ) {
48
    final VariableNameDecoratorFactory f = new VariableNameDecoratorFactory();
48
    final var factory = new VariableNameDecoratorFactory();
4949
    final VariableDecorator result;
5050
51
    switch( f.lookup( path ) ) {
51
    switch( factory.lookup( path ) ) {
5252
      case RMARKDOWN:
5353
      case RXML:
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
2929
3030
import com.scrivenvar.FileEditorTab;
31
import com.scrivenvar.Services;
32
import com.scrivenvar.decorators.VariableDecorator;
33
import com.scrivenvar.definition.DefinitionPane;
34
import com.scrivenvar.definition.VariableTreeItem;
35
import com.scrivenvar.service.Settings;
36
import javafx.collections.ObservableList;
37
import javafx.event.Event;
38
import javafx.event.EventHandler;
39
import javafx.scene.control.IndexRange;
40
import javafx.scene.control.TreeItem;
41
import javafx.scene.control.TreeView;
42
import javafx.scene.input.InputEvent;
43
import javafx.scene.input.KeyCode;
44
import javafx.scene.input.KeyEvent;
45
import javafx.scene.input.MouseEvent;
46
import org.fxmisc.richtext.StyledTextArea;
47
import org.fxmisc.wellbehaved.event.EventPattern;
48
import org.fxmisc.wellbehaved.event.InputMap;
49
50
import java.nio.file.Path;
51
import java.util.function.Consumer;
52
53
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR;
54
import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR;
55
import static com.scrivenvar.util.Lists.getFirst;
56
import static com.scrivenvar.util.Lists.getLast;
57
import static java.lang.Character.isWhitespace;
58
import static java.lang.Math.min;
59
import static javafx.scene.input.KeyCode.*;
60
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
61
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
62
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
63
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
64
import static org.fxmisc.wellbehaved.event.InputMap.consume;
65
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
66
67
/**
68
 * Provides the logic for injecting variable names within the editor.
69
 *
70
 * @author White Magic Software, Ltd.
71
 */
72
public final class VariableNameInjector {
73
74
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
75
76
  private static final int NO_DIFFERENCE = -1;
77
78
  /**
79
   * TODO: Move this into settings.
80
   */
81
  private static final String PUNCTUATION = "\"#$%&'()*+,-/:;<=>?@[]^_`{|}~";
82
83
  private final Settings settings = Services.load( Settings.class );
84
85
  /**
86
   * Used to capture keyboard events once the user presses @.
87
   */
88
  private InputMap<InputEvent> keyboardMap;
89
90
  /**
91
   * Position of the variable in the text when in variable mode (0 by default).
92
   */
93
  private int initialCaretPosition;
94
95
  /**
96
   * Recipient of name injections.
97
   */
98
  private FileEditorTab tab;
99
100
  /**
101
   * Initiates double-click events.
102
   */
103
  private DefinitionPane definitionPane;
104
105
  private EventHandler<MouseEvent> panelEventHandler;
106
107
  /**
108
   * Initializes the variable name injector against the given pane.
109
   *
110
   * @param tab  The tab to inject variable names into.
111
   * @param pane The definition panel to listen to for double-click events.
112
   */
113
  public VariableNameInjector(
114
      final FileEditorTab tab, final DefinitionPane pane ) {
115
    setFileEditorTab( tab );
116
    setDefinitionPane( pane );
117
    initBranchSelectedListener();
118
    initKeyboardEventListeners();
119
  }
120
121
  /**
122
   * Traps double-click events on the definition pane.
123
   */
124
  private void initBranchSelectedListener() {
125
    final EventHandler<MouseEvent> eventHandler = getPanelEventHandler();
126
    getDefinitionPane().addBranchSelectedListener( eventHandler );
127
  }
128
129
  /**
130
   * Trap control+space and the @ key.
131
   *
132
   * @param tab The file editor that sends keyboard events for variable name
133
   *            injection.
134
   */
135
  public void initKeyboardEventListeners( final FileEditorTab tab ) {
136
    setFileEditorTab( tab );
137
    initKeyboardEventListeners();
138
  }
139
140
  /**
141
   * Traps keys for performing various short-cut tasks, such as @-mode variable
142
   * insertion and control+space for variable autocomplete.
143
   *
144
   * @ key is pressed, a new keyboard map is inserted in place of the current
145
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
146
   */
147
  private void initKeyboardEventListeners() {
148
    // Control and space are pressed.
149
    addKeyboardListener( keyPressed( SPACE, CONTROL_DOWN ),
150
                         this::autocomplete );
151
152
    // @ key in Linux?
153
    addKeyboardListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
154
    // @ key in Windows.
155
    addKeyboardListener( keyPressed( AT ), this::vMode );
156
  }
157
158
  /**
159
   * The @ symbol is a short-cut to inserting a YAML variable reference.
160
   *
161
   * @param e Superfluous information about the key that was pressed.
162
   */
163
  private void vMode( KeyEvent e ) {
164
    setInitialCaretPosition();
165
    vModeStart();
166
    vModeAutocomplete();
167
  }
168
169
  /**
170
   * Receives key presses until the user completes the variable selection. This
171
   * allows the arrow keys to be used for selecting variables.
172
   *
173
   * @param e The key that was pressed.
174
   */
175
  private void vModeKeyPressed( KeyEvent e ) {
176
    final KeyCode keyCode = e.getCode();
177
178
    switch( keyCode ) {
179
      case BACK_SPACE:
180
        // Don't decorate the variable upon exiting vMode.
181
        vModeBackspace();
182
        break;
183
184
      case ESCAPE:
185
        // Don't decorate the variable upon exiting vMode.
186
        vModeStop();
187
        break;
188
189
      case ENTER:
190
      case PERIOD:
191
      case RIGHT:
192
      case END:
193
        // Stop at a leaf node, ENTER means accept.
194
        if( vModeConditionalComplete() && keyCode == ENTER ) {
195
          vModeStop();
196
197
          // Decorate the variable upon exiting vMode.
198
          decorate();
199
        }
200
        break;
201
202
      case UP:
203
        cyclePathPrev();
204
        break;
205
206
      case DOWN:
207
        cyclePathNext();
208
        break;
209
210
      default:
211
        vModeFilterKeyPressed( e );
212
        break;
213
    }
214
215
    e.consume();
216
  }
217
218
  private void vModeBackspace() {
219
    deleteSelection();
220
221
    // Break out of variable mode by back spacing to the original position.
222
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
223
      vModeAutocomplete();
224
    }
225
    else {
226
      vModeStop();
227
    }
228
  }
229
230
  /**
231
   * Updates the text with the path selected (or typed) by the user.
232
   */
233
  private void vModeAutocomplete() {
234
    final TreeItem<String> node = getCurrentNode();
235
236
    if( node != null && !node.isLeaf() ) {
237
      final String word = getLastPathWord();
238
      final String label = node.getValue();
239
      final int delta = difference( label, word );
240
      final String remainder = delta == NO_DIFFERENCE
241
          ? label
242
          : label.substring( delta );
243
244
      final StyledTextArea<?, ?> textArea = getEditor();
245
      final int posBegan = getCurrentCaretPosition();
246
      final int posEnded = posBegan + remainder.length();
247
248
      textArea.replaceSelection( remainder );
249
250
      if( posEnded - posBegan > 0 ) {
251
        textArea.selectRange( posEnded, posBegan );
252
      }
253
254
      expand( node );
255
    }
256
  }
257
258
  /**
259
   * Only variable name keys can pass through the filter. This is called when
260
   * the user presses a key.
261
   *
262
   * @param e The key that was pressed.
263
   */
264
  private void vModeFilterKeyPressed( final KeyEvent e ) {
265
    if( isVariableNameKey( e ) ) {
266
      typed( e.getText() );
267
    }
268
  }
269
270
  /**
271
   * Performs an autocomplete depending on whether the user has finished typing
272
   * in a word. If there is a selected range, then this will complete the most
273
   * recent word and jump to the next child.
274
   *
275
   * @return true The auto-completed node was a terminal node.
276
   */
277
  private boolean vModeConditionalComplete() {
278
    acceptPath();
279
280
    final TreeItem<String> node = getCurrentNode();
281
    final boolean terminal = isTerminal( node );
282
283
    if( !terminal ) {
284
      typed( SEPARATOR );
285
    }
286
287
    return terminal;
288
  }
289
290
  /**
291
   * Pressing control+space will find a node that matches the current word and
292
   * substitute the YAML variable reference. This is called when the user is not
293
   * editing in vMode.
294
   *
295
   * @param e Ignored -- it can only be Ctrl+Space.
296
   */
297
  private void autocomplete( final KeyEvent e ) {
298
    final String paragraph = getCaretParagraph();
299
    final int[] boundaries = getWordBoundaries( paragraph );
300
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
301
302
    VariableTreeItem<String> leaf = findLeaf( word );
303
304
    if( leaf == null ) {
305
      // If a leaf doesn't match using "starts with", then try using "contains".
306
      leaf = findLeaf( word, true );
307
    }
308
309
    if( leaf != null ) {
310
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
311
      decorate();
312
      expand( leaf );
313
    }
314
  }
315
316
  /**
317
   * Called when autocomplete finishes on a valid leaf or when the user presses
318
   * Enter to finish manual autocomplete.
319
   */
320
  private void decorate() {
321
    // A little bit of duplication...
322
    final String paragraph = getCaretParagraph();
323
    final int[] boundaries = getWordBoundaries( paragraph );
324
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
325
326
    final String newVariable = decorate( old );
327
328
    final int posEnded = getCurrentCaretPosition();
329
    final int posBegan = posEnded - old.length();
330
331
    getEditor().replaceText( posBegan, posEnded, newVariable );
332
  }
333
334
  /**
335
   * Called when user double-clicks on a tree view item.
336
   *
337
   * @param variable The variable to decorate.
338
   */
339
  private String decorate( final String variable ) {
340
    return getVariableDecorator().decorate( variable );
341
  }
342
343
  /**
344
   * Inserts the given string at the current caret position, or replaces
345
   * selected text (if any).
346
   *
347
   * @param s The string to inject.
348
   */
349
  private void replaceSelection( final String s ) {
350
    getEditor().replaceSelection( s );
351
  }
352
353
  /**
354
   * Updates the text at the given position within the current paragraph.
355
   *
356
   * @param posBegan The starting index in the paragraph text to replace.
357
   * @param posEnded The ending index in the paragraph text to replace.
358
   * @param text     Overwrite the paragraph substring with this text.
359
   */
360
  private void replaceText(
361
      final int posBegan, final int posEnded, final String text ) {
362
    final int p = getCurrentParagraph();
363
364
    getEditor().replaceText( p, posBegan, p, posEnded, text );
365
  }
366
367
  /**
368
   * Returns the caret's current paragraph position.
369
   *
370
   * @return A number greater than or equal to 0.
371
   */
372
  private int getCurrentParagraph() {
373
    return getEditor().getCurrentParagraph();
374
  }
375
376
  /**
377
   * Returns current word boundary indexes into the current paragraph, excluding
378
   * punctuation.
379
   *
380
   * @param p      The paragraph wherein to hunt word boundaries.
381
   * @param offset The offset into the paragraph to begin scanning left and
382
   *               right.
383
   * @return The starting and ending index of the word closest to the caret.
384
   */
385
  private int[] getWordBoundaries( final String p, final int offset ) {
386
    // Remove dashes, but retain hyphens. Retain same number of characters
387
    // to preserve relative indexes.
388
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
389
390
    return getWordAt( paragraph, offset );
391
  }
392
393
  /**
394
   * Helper method to get the word boundaries for the current paragraph.
395
   *
396
   * @param paragraph The paragraph to search for word boundaries.
397
   * @return The word boundary indexes into the paragraph.
398
   */
399
  private int[] getWordBoundaries( final String paragraph ) {
400
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
401
  }
402
403
  /**
404
   * Given an arbitrary offset into a string, this returns the word at that
405
   * index. The inputs and outputs include:
406
   *
407
   * <ul>
408
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
409
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
410
   * <li>start of a word: <code>hello |world!</code> ("world");</li>
411
   * <li>within a word: <code>hello wo|rld!</code> ("world");</li>
412
   * <li>end of a paragraph: <code>hello world!|</code> ("world");</li>
413
   * <li>start of a paragraph: <code>|hello world!</code> ("hello"); or</li>
414
   * <li>after punctuation: <code>hello world!|</code> ("world").</li>
415
   * </ul>
416
   *
417
   * @param p      The string to scan for a word.
418
   * @param offset The offset within s to begin searching for the nearest word
419
   *               boundary, must not be out of bounds of s.
420
   * @return The word in s at the offset.
421
   */
422
  private int[] getWordAt( final String p, final int offset ) {
423
    return new int[]{getWordBegan( p, offset ), getWordEnded( p, offset )};
424
  }
425
426
  /**
427
   * Returns the index into s where a word begins.
428
   *
429
   * @param s      Never null.
430
   * @param offset Index into s to begin searching backwards for a word
431
   *               boundary.
432
   * @return The index where a word begins.
433
   */
434
  private int getWordBegan( final String s, int offset ) {
435
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
436
      offset--;
437
    }
438
439
    return offset;
440
  }
441
442
  /**
443
   * Returns the index into s where a word ends.
444
   *
445
   * @param s      Never null.
446
   * @param offset Index into s to begin searching forwards for a word boundary.
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
   * @return false The character is a space character.
465
   */
466
  private boolean isBoundary( final char c ) {
467
    return !isWhitespace( c ) && !isPunctuation( c );
468
  }
469
470
  /**
471
   * Returns true if the given character is part of the set of Latin (English)
472
   * punctuation marks.
473
   *
474
   * @param c The character to determine whether it is punctuation.
475
   * @return {@code true} when the given character is in the set of
476
   * {@link #PUNCTUATION}.
477
   */
478
  private static boolean isPunctuation( final char c ) {
479
    return PUNCTUATION.indexOf( c ) != -1;
480
  }
481
482
  /**
483
   * Returns the text for the paragraph that contains the caret.
484
   *
485
   * @return A non-null string, possibly empty.
486
   */
487
  private String getCaretParagraph() {
488
    return getEditor().getText( getCurrentParagraph() );
489
  }
490
491
  /**
492
   * Returns true if the node has children that can be selected (i.e., any
493
   * non-leaves).
494
   *
495
   * @param <T>  The type that the TreeItem contains.
496
   * @param node The node to test for terminality.
497
   * @return true The node has one branch and its a leaf.
498
   */
499
  private <T> boolean isTerminal( final TreeItem<T> node ) {
500
    final ObservableList<TreeItem<T>> branches = node.getChildren();
501
502
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
503
  }
504
505
  /**
506
   * Inserts text that the user typed at the current caret position, then
507
   * performs an autocomplete for the variable name.
508
   *
509
   * @param text The text to insert, never null.
510
   */
511
  private void typed( final String text ) {
512
    getEditor().replaceSelection( text );
513
    vModeAutocomplete();
514
  }
515
516
  /**
517
   * Called when the user presses either End or Enter key.
518
   */
519
  private void acceptPath() {
520
    final IndexRange range = getSelectionRange();
521
522
    if( range != null ) {
523
      final int rangeEnd = range.getEnd();
524
      final StyledTextArea<?, ?> textArea = getEditor();
525
      textArea.deselect();
526
      textArea.moveTo( rangeEnd );
527
    }
528
  }
529
530
  /**
531
   * Replaces the entirety of the existing path (from the initial caret
532
   * position) with the given path.
533
   *
534
   * @param oldPath The path to replace.
535
   * @param newPath The replacement path.
536
   */
537
  private void replacePath( final String oldPath, final String newPath ) {
538
    final StyledTextArea<?, ?> textArea = getEditor();
539
    final int posBegan = getInitialCaretPosition();
540
    final int posEnded = posBegan + oldPath.length();
541
542
    textArea.deselect();
543
    textArea.replaceText( posBegan, posEnded, newPath );
544
  }
545
546
  /**
547
   * Called when the user presses the Backspace key.
548
   */
549
  private void deleteSelection() {
550
    final StyledTextArea<?, ?> textArea = getEditor();
551
    textArea.replaceSelection( "" );
552
    textArea.deletePreviousChar();
553
  }
554
555
  /**
556
   * Cycles the selected text through the nodes.
557
   *
558
   * @param direction true - next; false - previous
559
   */
560
  private void cycleSelection( final boolean direction ) {
561
    final TreeItem<String> node = getCurrentNode();
562
563
    // Find the sibling for the current selection and replace the current
564
    // selection with the sibling's value
565
    TreeItem<String> cycled = direction
566
        ? node.nextSibling()
567
        : node.previousSibling();
568
569
    // When cycling at the end (or beginning) of the list, jump to the first
570
    // (or last) sibling depending on the cycle direction.
571
    if( cycled == null ) {
572
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
573
    }
574
575
    final String path = getCurrentPath();
576
    final String cycledWord = cycled.getValue();
577
    final String word = getLastPathWord();
578
    final int index = path.indexOf( word );
579
    final String cycledPath = path.substring( 0, index ) + cycledWord;
580
581
    expand( cycled );
582
    replacePath( path, cycledPath );
583
  }
584
585
  /**
586
   * Cycles to the next sibling of the currently selected tree node.
587
   */
588
  private void cyclePathNext() {
589
    cycleSelection( true );
590
  }
591
592
  /**
593
   * Cycles to the previous sibling of the currently selected tree node.
594
   */
595
  private void cyclePathPrev() {
596
    cycleSelection( false );
597
  }
598
599
  /**
600
   * Returns the variable name (or as much as has been typed so far). Returns
601
   * all the characters from the initial caret column to the the first
602
   * whitespace character. This will return a path that contains zero or more
603
   * separators.
604
   *
605
   * @return A non-null string, possibly empty.
606
   */
607
  private String getCurrentPath() {
608
    final String s = extractTextChunk();
609
    final int length = s.length();
610
611
    int i = 0;
612
613
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
614
      i++;
615
    }
616
617
    return s.substring( 0, i );
618
  }
619
620
  private <T> ObservableList<TreeItem<T>> getSiblings(
621
      final TreeItem<T> item ) {
622
    final TreeItem<T> parent = item.getParent();
623
    return parent == null ? item.getChildren() : parent.getChildren();
624
  }
625
626
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
627
    return getFirst( getSiblings( item ), item );
628
  }
629
630
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
631
    return getLast( getSiblings( item ), item );
632
  }
633
634
  /**
635
   * Returns the caret position as an offset into the text.
636
   *
637
   * @return A value from 0 to the length of the text (minus one).
638
   */
639
  private int getCurrentCaretPosition() {
640
    return getEditor().getCaretPosition();
641
  }
642
643
  /**
644
   * Returns the caret position within the current paragraph.
645
   *
646
   * @return A value from 0 to the length of the current paragraph.
647
   */
648
  private int getCurrentCaretColumn() {
649
    return getEditor().getCaretColumn();
650
  }
651
652
  /**
653
   * Returns the last word from the path.
654
   *
655
   * @return The last token.
656
   */
657
  private String getLastPathWord() {
658
    String path = getCurrentPath();
659
660
    int i = path.indexOf( SEPARATOR_CHAR );
661
662
    while( i > 0 ) {
663
      path = path.substring( i + 1 );
664
      i = path.indexOf( SEPARATOR_CHAR );
665
    }
666
667
    return path;
668
  }
669
670
  /**
671
   * Returns text from the initial caret position until some arbitrarily long
672
   * number of characters. The number of characters extracted will be
673
   * getMaxVarLength, or fewer, depending on how many characters remain to be
674
   * extracted. The result from this method is trimmed to the first whitespace
675
   * character.
676
   *
677
   * @return A chunk of text that includes all the words representing a path,
678
   * and then some.
679
   */
680
  private String extractTextChunk() {
681
    final StyledTextArea<?, ?> textArea = getEditor();
682
    final int textBegan = getInitialCaretPosition();
683
    final int remaining = textArea.getLength() - textBegan;
684
    final int textEnded = min( remaining, getMaxVarLength() );
685
686
    try {
687
      return textArea.getText( textBegan, textEnded );
688
    } catch( final Exception e ) {
689
      return textArea.getText();
690
    }
691
  }
692
693
  /**
694
   * Returns the node for the current path.
695
   */
696
  private TreeItem<String> getCurrentNode() {
697
    return findNode( getCurrentPath() );
698
  }
699
700
  /**
701
   * Finds the node that most closely matches the given path.
702
   *
703
   * @param path The path that represents a node.
704
   * @return The node for the path, or the root node if the path could not be
705
   * found, but never null.
706
   */
707
  private TreeItem<String> findNode( final String path ) {
708
    return getDefinitionPane().findNode( path );
709
  }
710
711
  /**
712
   * Finds the first leaf having a value that starts with the given text.
713
   *
714
   * @param text The text to find in the definition tree.
715
   * @return The leaf that starts with the given text, or null if not found.
716
   */
717
  private VariableTreeItem<String> findLeaf( final String text ) {
718
    return getDefinitionPane().findLeaf( text, false );
719
  }
720
721
  /**
722
   * Finds the first leaf having a value that starts with the given text, or
723
   * contains the text if contains is true.
724
   *
725
   * @param text     The text to find in the definition tree.
726
   * @param contains Set true to perform a substring match after a starts with
727
   *                 match.
728
   * @return The leaf that starts with the given text, or null if not found.
729
   */
730
  @SuppressWarnings("SameParameterValue")
731
  private VariableTreeItem<String> findLeaf(
732
      final String text, final boolean contains ) {
733
    return getDefinitionPane().findLeaf( text, contains );
734
  }
735
736
  /**
737
   * Used to ignore typed keys in favour of trapping pressed keys.
738
   *
739
   * @param e The key that was typed.
740
   */
741
  private void vModeKeyTyped( KeyEvent e ) {
742
    e.consume();
743
  }
744
745
  /**
746
   * Used to lazily initialize the keyboard map.
747
   *
748
   * @return Mappings for keyTyped and keyPressed.
749
   */
750
  protected InputMap<InputEvent> createKeyboardMap() {
751
    return sequence(
752
        consume( keyTyped(), this::vModeKeyTyped ),
753
        consume( keyPressed(), this::vModeKeyPressed )
754
    );
755
  }
756
757
  private InputMap<InputEvent> getKeyboardMap() {
758
    if( this.keyboardMap == null ) {
759
      this.keyboardMap = createKeyboardMap();
760
    }
761
762
    return this.keyboardMap;
763
  }
764
765
  /**
766
   * Collapses the tree then expands and selects the given node.
767
   *
768
   * @param node The node to expand.
769
   */
770
  private void expand( final TreeItem<String> node ) {
771
    final DefinitionPane pane = getDefinitionPane();
772
    pane.collapse();
773
    pane.expand( node );
774
    pane.select( node );
775
  }
776
777
  /**
778
   * Returns true iff the key code the user typed can be used as part of a YAML
779
   * variable name.
780
   *
781
   * @param keyEvent Keyboard key press event information.
782
   * @return true The key is a value that can be inserted into the text.
783
   */
784
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
785
    final KeyCode kc = keyEvent.getCode();
786
787
    return (kc.isLetterKey()
788
        || kc.isDigitKey()
789
        || (keyEvent.isShiftDown() && kc == MINUS))
790
        && !keyEvent.isControlDown();
791
  }
792
793
  /**
794
   * Starts to capture user input events.
795
   */
796
  private void vModeStart() {
797
    addEventListener( getKeyboardMap() );
798
  }
799
800
  /**
801
   * Restores capturing of user input events to the previous event listener.
802
   * Also asks the processing chain to modify the variable text into a
803
   * machine-readable variable based on the format required by the file type.
804
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
805
   * file (.Rmd, .Rxml) will use `r#xVAR`.
806
   */
807
  private void vModeStop() {
808
    removeEventListener( getKeyboardMap() );
809
  }
810
811
  /**
812
   * @return A variable decorator that corresponds to the given file type.
813
   */
814
  private VariableDecorator getVariableDecorator() {
815
    return VariableNameDecoratorFactory.newInstance( getFilename() );
816
  }
817
818
  private Path getFilename() {
819
    return getFileEditorTab().getPath();
820
  }
821
822
  /**
823
   * Returns the index where the two strings diverge.
824
   *
825
   * @param s1 The string that could be a substring of s2, null allowed.
826
   * @param s2 The string that could be a substring of s1, null allowed.
827
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
828
   * where they differ.
829
   */
830
  private int difference( final CharSequence s1, final CharSequence s2 ) {
831
    if( s1 == s2 ) {
832
      return NO_DIFFERENCE;
833
    }
834
835
    if( s1 == null || s2 == null ) {
836
      return 0;
837
    }
838
839
    int i = 0;
840
    final int limit = min( s1.length(), s2.length() );
841
842
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
843
      i++;
844
    }
845
846
    // If one string was shorter than the other, that's where they differ.
847
    return i;
848
  }
849
850
  private EditorPane getEditorPane() {
851
    return getFileEditorTab().getEditorPane();
852
  }
853
854
  /**
855
   * Delegates to the file editor pane, and, ultimately, to its text area.
856
   */
857
  private <T extends Event, U extends T> void addKeyboardListener(
858
      final EventPattern<? super T, ? extends U> event,
859
      final Consumer<? super U> consumer ) {
860
    getEditorPane().addKeyboardListener( event, consumer );
861
  }
862
863
  /**
864
   * Delegates to the file editor pane, and, ultimately, to its text area.
865
   *
866
   * @param map The map of methods to events.
867
   */
868
  private void addEventListener( final InputMap<InputEvent> map ) {
869
    getEditorPane().addEventListener( map );
870
  }
871
872
  private void removeEventListener( final InputMap<InputEvent> map ) {
873
    getEditorPane().removeEventListener( map );
874
  }
875
876
  /**
877
   * Returns the position of the caret when variable mode editing was requested.
878
   *
879
   * @return The variable mode caret position.
880
   */
881
  private int getInitialCaretPosition() {
882
    return this.initialCaretPosition;
883
  }
884
885
  /**
886
   * Sets the position of the caret when variable mode editing was requested.
887
   * Stores the current position because only the text that comes afterwards is
888
   * a suitable variable reference.
889
   */
890
  private void setInitialCaretPosition() {
891
    this.initialCaretPosition = getEditor().getCaretPosition();
892
  }
893
894
  private StyledTextArea<?, ?> getEditor() {
895
    return getEditorPane().getEditor();
896
  }
897
898
  public FileEditorTab getFileEditorTab() {
899
    return this.tab;
900
  }
901
902
  public void setFileEditorTab( final FileEditorTab editorTab ) {
903
    this.tab = editorTab;
904
  }
905
906
  private DefinitionPane getDefinitionPane() {
907
    return this.definitionPane;
908
  }
909
910
  private void setDefinitionPane( final DefinitionPane definitionPane ) {
911
    this.definitionPane = definitionPane;
912
  }
913
914
  private IndexRange getSelectionRange() {
915
    return getEditor().getSelection();
916
  }
917
918
  /**
919
   * Don't look ahead too far when trying to find the end of a node.
920
   *
921
   * @return 512 by default.
922
   */
923
  private int getMaxVarLength() {
924
    return getSettings().getSetting(
925
        "editor.variable.maxLength", DEFAULT_MAX_VAR_LENGTH );
926
  }
927
928
  private Settings getSettings() {
929
    return this.settings;
930
  }
931
932
  private synchronized EventHandler<MouseEvent> getPanelEventHandler() {
933
    if( this.panelEventHandler == null ) {
934
      this.panelEventHandler = createPanelEventHandler();
935
    }
936
937
    return this.panelEventHandler;
938
  }
939
940
  private EventHandler<MouseEvent> createPanelEventHandler() {
941
    return new PanelEventHandler();
942
  }
943
944
  /**
945
   * Responsible for handling double-click events on the definition pane.
946
   */
947
  private class PanelEventHandler implements EventHandler<MouseEvent> {
948
949
    public PanelEventHandler() {
950
    }
951
952
    @Override
953
    public void handle( final MouseEvent event ) {
954
      final Object source = event.getSource();
955
956
      if( source instanceof TreeView ) {
957
        final TreeView<?> tree = (TreeView<?>) source;
958
        final TreeItem<?> item = tree.getSelectionModel().getSelectedItem();
959
960
        if( item instanceof VariableTreeItem ) {
961
          final VariableTreeItem<?> var = (VariableTreeItem<?>) item;
962
          final String text = decorate( var.toPath() );
963
964
          replaceSelection( text );
965
        }
966
      }
967
    }
31
import com.scrivenvar.decorators.VariableDecorator;
32
import com.scrivenvar.definition.DefinitionPane;
33
import com.scrivenvar.definition.FindMode;
34
import com.scrivenvar.definition.VariableTreeItem;
35
import javafx.event.Event;
36
import javafx.scene.control.TreeItem;
37
import javafx.scene.input.KeyEvent;
38
import org.fxmisc.richtext.StyledTextArea;
39
import org.fxmisc.wellbehaved.event.EventPattern;
40
41
import java.nio.file.Path;
42
import java.text.BreakIterator;
43
import java.util.function.Consumer;
44
45
import static com.scrivenvar.definition.FindMode.*;
46
import static javafx.scene.input.KeyCode.SPACE;
47
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
48
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
49
50
/**
51
 * Provides the logic for injecting variable names within the editor.
52
 *
53
 * @author White Magic Software, Ltd.
54
 */
55
public final class VariableNameInjector {
56
57
  /**
58
   * Recipient of name injections.
59
   */
60
  private FileEditorTab mTab;
61
62
  /**
63
   * Initiates double-click events.
64
   */
65
  private DefinitionPane mDefinitionPane;
66
67
  /**
68
   * Initializes the variable name injector against the given pane.
69
   *
70
   * @param tab  The tab to inject variable names into.
71
   * @param pane The definition panel to listen to for double-click events.
72
   */
73
  public VariableNameInjector(
74
      final FileEditorTab tab, final DefinitionPane pane ) {
75
    setFileEditorTab( tab );
76
    setDefinitionPane( pane );
77
    initKeyboardEventListeners();
78
  }
79
80
  /**
81
   * Trap control+space and the @ key.
82
   *
83
   * @param tab The file editor that sends keyboard events for variable name
84
   *            injection.
85
   */
86
  public void initKeyboardEventListeners( final FileEditorTab tab ) {
87
    setFileEditorTab( tab );
88
    initKeyboardEventListeners();
89
  }
90
91
  /**
92
   * Traps Control+SPACE to auto-insert definition key names.
93
   */
94
  private void initKeyboardEventListeners() {
95
    addKeyboardListener(
96
        keyPressed( SPACE, CONTROL_DOWN ),
97
        this::autoinsert
98
    );
99
  }
100
101
  /**
102
   * Pressing Control+SPACE will find a node that matches the current word and
103
   * substitute the YAML variable reference.
104
   *
105
   * @param e Ignored -- it can only be Control+SPACE.
106
   */
107
  private void autoinsert( final KeyEvent e ) {
108
    final String paragraph = getCaretParagraph();
109
    final int[] boundaries = getWordBoundariesAtCaret();
110
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
111
    final VariableTreeItem<String> leaf = findLeaf( word );
112
113
    if( leaf != null ) {
114
      replaceText(
115
          boundaries[ 0 ],
116
          boundaries[ 1 ],
117
          decorate( leaf.toPath() )
118
      );
119
120
      expand( leaf );
121
    }
122
  }
123
124
  private int[] getWordBoundariesAtCaret() {
125
    final String paragraph = getCaretParagraph();
126
    int offset = getCurrentCaretColumn();
127
128
    final BreakIterator wordBreaks = BreakIterator.getWordInstance();
129
    wordBreaks.setText( paragraph );
130
131
    // Scan back until the first word is found.
132
    while( offset > 0 && wordBreaks.isBoundary( offset ) ) {
133
      offset--;
134
    }
135
136
    final int[] boundaries = new int[ 2 ];
137
    boundaries[ 1 ] = wordBreaks.following( offset );
138
    boundaries[ 0 ] = wordBreaks.previous();
139
140
    return boundaries;
141
  }
142
143
  /**
144
   * Injects a variable using the syntax specific to the type of document
145
   * being edited.
146
   *
147
   * @param variable The variable to decorate in dot-notation without any
148
   *                 start or end tokens present.
149
   */
150
  private String decorate( final String variable ) {
151
    return getVariableDecorator().decorate( variable );
152
  }
153
154
  /**
155
   * Updates the text at the given position within the current paragraph.
156
   *
157
   * @param posBegan The starting index in the paragraph text to replace.
158
   * @param posEnded The ending index in the paragraph text to replace.
159
   * @param text     Overwrite the paragraph substring with this text.
160
   */
161
  private void replaceText(
162
      final int posBegan, final int posEnded, final String text ) {
163
    final int p = getCurrentParagraph();
164
165
    getEditor().replaceText( p, posBegan, p, posEnded, text );
166
  }
167
168
  /**
169
   * Returns the caret's current paragraph position.
170
   *
171
   * @return A number greater than or equal to 0.
172
   */
173
  private int getCurrentParagraph() {
174
    return getEditor().getCurrentParagraph();
175
  }
176
177
  /**
178
   * Returns the text for the paragraph that contains the caret.
179
   *
180
   * @return A non-null string, possibly empty.
181
   */
182
  private String getCaretParagraph() {
183
    return getEditor().getText( getCurrentParagraph() );
184
  }
185
186
  /**
187
   * Returns the caret position within the current paragraph.
188
   *
189
   * @return A value from 0 to the length of the current paragraph.
190
   */
191
  private int getCurrentCaretColumn() {
192
    return getEditor().getCaretColumn();
193
  }
194
195
  private VariableTreeItem<String> findLeaf( final String word ) {
196
    assert word != null;
197
198
    VariableTreeItem<String> leaf;
199
200
    leaf = findLeafStartsWith( word );
201
    leaf = leaf == null ? findLeafContains( word ) : leaf;
202
    leaf = leaf == null ? findLeafLevenshtein( word ) : leaf;
203
204
    return leaf;
205
  }
206
207
  private VariableTreeItem<String> findLeafContains( final String text ) {
208
    return findLeaf( text, CONTAINS );
209
  }
210
211
  private VariableTreeItem<String> findLeafStartsWith( final String text ) {
212
    return findLeaf( text, STARTS_WITH );
213
  }
214
215
  private VariableTreeItem<String> findLeafLevenshtein( final String text ) {
216
    return findLeaf( text, LEVENSHTEIN );
217
  }
218
219
  /**
220
   * Finds the first leaf having a value that starts with the given text, or
221
   * contains the text if contains is true.
222
   *
223
   * @param text     The text to find in the definition tree.
224
   * @param findMode Dictates what search criteria to use for matching words.
225
   * @return The leaf that starts with the given text, or null if not found.
226
   */
227
  private VariableTreeItem<String> findLeaf(
228
      final String text, final FindMode findMode ) {
229
    return getDefinitionPane().findLeaf( text, findMode );
230
  }
231
232
  /**
233
   * Collapses the tree then expands and selects the given node.
234
   *
235
   * @param node The node to expand.
236
   */
237
  private void expand( final TreeItem<String> node ) {
238
    final DefinitionPane pane = getDefinitionPane();
239
    pane.collapse();
240
    pane.expand( node );
241
    pane.select( node );
242
  }
243
244
  /**
245
   * @return A variable decorator that corresponds to the given file type.
246
   */
247
  private VariableDecorator getVariableDecorator() {
248
    return VariableNameDecoratorFactory.newInstance( getFilename() );
249
  }
250
251
  private Path getFilename() {
252
    return getFileEditorTab().getPath();
253
  }
254
255
  private EditorPane getEditorPane() {
256
    return getFileEditorTab().getEditorPane();
257
  }
258
259
  /**
260
   * Delegates to the file editor pane, and, ultimately, to its text area.
261
   */
262
  private <T extends Event, U extends T> void addKeyboardListener(
263
      final EventPattern<? super T, ? extends U> event,
264
      final Consumer<? super U> consumer ) {
265
    getEditorPane().addKeyboardListener( event, consumer );
266
  }
267
268
  private StyledTextArea<?, ?> getEditor() {
269
    return getEditorPane().getEditor();
270
  }
271
272
  public FileEditorTab getFileEditorTab() {
273
    return mTab;
274
  }
275
276
  public void setFileEditorTab( final FileEditorTab tab ) {
277
    mTab = tab;
278
  }
279
280
  private DefinitionPane getDefinitionPane() {
281
    return mDefinitionPane;
282
  }
283
284
  private void setDefinitionPane( final DefinitionPane definitionPane ) {
285
    mDefinitionPane = definitionPane;
968286
  }
969287
}
M src/main/java/com/scrivenvar/processors/AbstractProcessor.java
5050
   * Used while processing the entire chain; null to signify no more links.
5151
   */
52
  private final Processor<T> next;
52
  private final Processor<T> mNext;
5353
5454
  /**
...
6565
   */
6666
  public AbstractProcessor( final Processor<T> successor ) {
67
    this.next = successor;
67
    mNext = successor;
6868
  }
6969
...
8686
  @Override
8787
  public Processor<T> next() {
88
    return this.next;
88
    return mNext;
8989
  }
9090
}
D src/main/java/com/scrivenvar/processors/DefaultVariableProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import static com.scrivenvar.processors.text.TextReplacementFactory.replace;
31
import java.util.Map;
32
33
/**
34
 * Processes variables in the document and inserts their values into the
35
 * post-processed text. The default variable syntax is <code>$variable$</code>.
36
 *
37
 * @author White Magic Software, Ltd.
38
 */
39
public class DefaultVariableProcessor extends AbstractProcessor<String> {
40
41
  private Map<String, String> definitions;
42
43
  /**
44
   * Constructs a variable processor to dereference variables.
45
   *
46
   * @param successor Usually the HTML Preview Processor.
47
   */
48
  private DefaultVariableProcessor( final Processor<String> successor ) {
49
    super( successor );
50
  }
51
52
  public DefaultVariableProcessor(
53
    final Processor<String> successor, final Map<String, String> map ) {
54
    this( successor );
55
    setDefinitions( map );
56
  }
57
58
  /**
59
   * Processes the given text document by replacing variables with their values.
60
   *
61
   * @param text The document text that includes variables that should be
62
   * replaced with values when rendered as HTML.
63
   *
64
   * @return The text with all variables replaced.
65
   */
66
  @Override
67
  public String processLink( final String text ) {
68
    return replace( text, getDefinitions() );
69
  }
70
71
  /**
72
   * Returns the map to use for variable substitution.
73
   *
74
   * @return A map of variable names to values.
75
   */
76
  protected Map<String, String> getDefinitions() {
77
    return this.definitions;
78
  }
79
80
  private void setDefinitions( final Map<String, String> definitions ) {
81
    this.definitions = definitions;
82
  }
83
}
841
A src/main/java/com/scrivenvar/processors/DefinitionProcessor.java
1
/*
2
 * Copyright 2016 White Magic Software, Ltd.
3
 *
4
 * All rights reserved.
5
 *
6
 * Redistribution and use in source and binary forms, with or without
7
 * modification, are permitted provided that the following conditions are met:
8
 *
9
 *  o Redistributions of source code must retain the above copyright
10
 *    notice, this list of conditions and the following disclaimer.
11
 *
12
 *  o Redistributions in binary form must reproduce the above copyright
13
 *    notice, this list of conditions and the following disclaimer in the
14
 *    documentation and/or other materials provided with the distribution.
15
 *
16
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
20
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
24
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
 */
28
package com.scrivenvar.processors;
29
30
import java.util.Map;
31
32
import static com.scrivenvar.processors.text.TextReplacementFactory.replace;
33
34
/**
35
 * Processes interpolated string definitions in the document and inserts
36
 * their values into the post-processed text. The default variable syntax is
37
 * {@code $variable$}.
38
 *
39
 * @author White Magic Software, Ltd.
40
 */
41
public class DefinitionProcessor extends AbstractProcessor<String> {
42
43
  private final Map<String, String> mDefinitions;
44
45
  public DefinitionProcessor(
46
      final Processor<String> successor, final Map<String, String> map ) {
47
    super( successor );
48
    mDefinitions = map;
49
  }
50
51
  /**
52
   * Processes the given text document by replacing variables with their values.
53
   *
54
   * @param text The document text that includes variables that should be
55
   *             replaced with values when rendered as HTML.
56
   * @return The text with all variables replaced.
57
   */
58
  @Override
59
  public String processLink( final String text ) {
60
    return replace( text, getDefinitions() );
61
  }
62
63
  /**
64
   * Returns the map to use for variable substitution.
65
   *
66
   * @return A map of variable names to values.
67
   */
68
  protected Map<String, String> getDefinitions() {
69
    return mDefinitions;
70
  }
71
}
172
M src/main/java/com/scrivenvar/processors/InlineRProcessor.java
5151
 * @author White Magic Software, Ltd.
5252
 */
53
public final class InlineRProcessor extends DefaultVariableProcessor {
53
public final class InlineRProcessor extends DefinitionProcessor {
5454
5555
  private static final Notifier NOTIFIER = Services.load( Notifier.class );
...
8080
      final Path wd = getWorkingDirectory();
8181
      final String dir = wd.toString().replace( '\\', '/' );
82
      final Map<String, String> definitions = getDefinitions();
83
      definitions.put( "$application.r.working.directory$", dir );
82
      final Map<String, String> map = getDefinitions();
83
      map.put( "$application.r.working.directory$", dir );
8484
8585
      final String initScript = getInitScript();
8686
87
      if( !initScript.isEmpty() ) {
88
        final String rScript = replace( initScript, getDefinitions() );
89
        eval( rScript );
87
      if( !initScript.isBlank() ) {
88
        eval( replace( initScript, map ) );
9089
      }
9190
    } catch( final Exception e ) {
92
      // Tell the user that there was a problem.
93
      getNotifier().notify( e.getMessage() );
91
      getNotifier().notify( e );
9492
    }
9593
  }
M src/main/java/com/scrivenvar/processors/ProcessorFactory.java
4444
public class ProcessorFactory extends AbstractFileFactory {
4545
46
  private final HTMLPreviewPane previewPane;
47
  private final Map<String, String> resolvedMap;
48
49
  private Processor<String> terminalProcessChain;
46
  private final HTMLPreviewPane mPreviewPane;
47
  private final Map<String, String> mResolvedMap;
48
  private final Processor<String> mCommonProcessor;
5049
5150
  /**
5251
   * Constructs a factory with the ability to create processors that can perform
5352
   * text and caret processing to generate a final preview.
5453
   *
5554
   * @param previewPane Where the final output is rendered.
56
   * @param resolvedMap Map of definitions to replace before final render.
55
   * @param resolvedMap Flat map of definitions to replace before final render.
5756
   */
5857
  public ProcessorFactory(
5958
      final HTMLPreviewPane previewPane,
6059
      final Map<String, String> resolvedMap ) {
61
    this.previewPane = previewPane;
62
    this.resolvedMap = resolvedMap;
60
    mPreviewPane = previewPane;
61
    mResolvedMap = resolvedMap;
62
    mCommonProcessor = createCommonProcessor();
6363
  }
6464
...
9797
9898
    return processor;
99
  }
100
101
  /**
102
   * Returns a processor common to all processors: markdown, caret position
103
   * token replacer, and an HTML preview renderer.
104
   *
105
   * @return Processors at the end of the processing chain.
106
   */
107
  private Processor<String> getCommonProcessor() {
108
    if( this.terminalProcessChain == null ) {
109
      this.terminalProcessChain = createCommonProcessor();
110
    }
111
112
    return this.terminalProcessChain;
11399
  }
114100
115101
  /**
116102
   * Creates and links the processors at the end of the processing chain.
117103
   *
118104
   * @return A markdown, caret replacement, and preview pane processor chain.
119105
   */
120106
  private Processor<String> createCommonProcessor() {
121
    final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() );
122
    final Processor<String> mcrp = new CaretReplacementProcessor( hpp );
107
    final var hpp = new HTMLPreviewProcessor( getPreviewPane() );
108
    final var mcrp = new CaretReplacementProcessor( hpp );
123109
124110
    return new MarkdownProcessor( mcrp );
125111
  }
126112
127113
  protected Processor<String> createIdentityProcessor() {
128
    final Processor<String> hpp = new HTMLPreviewProcessor( getPreviewPane() );
114
    final var hpp = new HTMLPreviewProcessor( getPreviewPane() );
129115
130116
    return new IdentityProcessor( hpp );
117
  }
118
119
  protected Processor<String> createDefinitionProcessor(
120
      final Processor<String> p ) {
121
    return new DefinitionProcessor( p, getResolvedMap() );
131122
  }
132123
133124
  protected Processor<String> createMarkdownProcessor(
134125
      final FileEditorTab tab ) {
135
    final ObservableValue<Integer> caret = tab.caretPositionProperty();
136
    final Processor<String> tpc = getCommonProcessor();
137
    final Processor<String> cip = createMarkdownInsertionProcessor(
138
        tpc, caret );
126
    final var caret = tab.caretPositionProperty();
127
    final var tpc = getCommonProcessor();
128
    final var cip = createMarkdownInsertionProcessor( tpc, caret );
139129
140
    return new DefaultVariableProcessor( cip, getResolvedMap() );
130
    return createDefinitionProcessor( cip );
141131
  }
142132
143133
  protected Processor<String> createXMLProcessor( final FileEditorTab tab ) {
144
    final ObservableValue<Integer> caret = tab.caretPositionProperty();
145
    final Processor<String> tpc = getCommonProcessor();
146
    final Processor<String> xmlp = new XMLProcessor( tpc, tab.getPath() );
147
    final Processor<String> dvp = new DefaultVariableProcessor(
148
        xmlp, getResolvedMap() );
134
    final var caret = tab.caretPositionProperty();
135
    final var tpc = getCommonProcessor();
136
    final var xmlp = new XMLProcessor( tpc, tab.getPath() );
137
    final var dp = createDefinitionProcessor( xmlp );
149138
150
    return createXMLInsertionProcessor( dvp, caret );
139
    return createXMLInsertionProcessor( dp, caret );
151140
  }
152141
153142
  protected Processor<String> createRProcessor( final FileEditorTab tab ) {
154
    final ObservableValue<Integer> caret = tab.caretPositionProperty();
155
    final Processor<String> tpc = getCommonProcessor();
156
    final Processor<String> rp = new InlineRProcessor( tpc, getResolvedMap() );
157
    final Processor<String> rvp = new RVariableProcessor(
158
        rp, getResolvedMap() );
143
    final var caret = tab.caretPositionProperty();
144
    final var tpc = getCommonProcessor();
145
    final var rp = new InlineRProcessor( tpc, getResolvedMap() );
146
    final var rvp = new RVariableProcessor( rp, getResolvedMap() );
159147
160148
    return createRInsertionProcessor( rvp, caret );
161149
  }
162150
163151
  protected Processor<String> createRXMLProcessor( final FileEditorTab tab ) {
164
    final ObservableValue<Integer> caret = tab.caretPositionProperty();
165
    final Processor<String> tpc = getCommonProcessor();
166
    final Processor<String> xmlp = new XMLProcessor( tpc, tab.getPath() );
167
    final Processor<String> rp = new InlineRProcessor( xmlp, getResolvedMap() );
168
    final Processor<String> rvp = new RVariableProcessor(
169
        rp, getResolvedMap() );
152
    final var caret = tab.caretPositionProperty();
153
    final var tpc = getCommonProcessor();
154
    final var xmlp = new XMLProcessor( tpc, tab.getPath() );
155
    final var rp = new InlineRProcessor( xmlp, getResolvedMap() );
156
    final var rvp = new RVariableProcessor( rp, getResolvedMap() );
170157
171158
    return createXMLInsertionProcessor( rvp, caret );
...
198185
199186
  private HTMLPreviewPane getPreviewPane() {
200
    return this.previewPane;
187
    return mPreviewPane;
201188
  }
202189
203190
  /**
204191
   * Returns the variable map of interpolated definitions.
205192
   *
206193
   * @return A map to help dereference variables.
207194
   */
208195
  private Map<String, String> getResolvedMap() {
209
    return this.resolvedMap;
196
    return mResolvedMap;
197
  }
198
199
  /**
200
   * Returns a processor common to all processors: markdown, caret position
201
   * token replacer, and an HTML preview renderer.
202
   *
203
   * @return Processors at the end of the processing chain.
204
   */
205
  private Processor<String> getCommonProcessor() {
206
    return mCommonProcessor;
210207
  }
211208
}
M src/main/java/com/scrivenvar/processors/RVariableProcessor.java
3434
 * Converts the keys of the resolved map from default form to R form, then
3535
 * performs a substitution on the text. The default R variable syntax is
36
 * <code>v$tree$leaf</code>.
36
 * {@code v$tree$leaf}.
3737
 *
3838
 * @author White Magic Software, Ltd.
3939
 */
40
public class RVariableProcessor extends DefaultVariableProcessor {
40
public class RVariableProcessor extends DefinitionProcessor {
4141
4242
  public RVariableProcessor(
M src/main/java/com/scrivenvar/service/events/Notifier.java
138138
   */
139139
  void addObserver( Observer observer );
140
141
  /**
142
   * Removes an observer from the list of objects that receive notifications
143
   * about error messages to be presented to the user.
144
   *
145
   * @param observer The observer instance to no longer notify.
146
   */
147
  void deleteObserver( Observer observer );
148140
}
149141
M src/main/java/com/scrivenvar/service/events/impl/DefaultNotifier.java
3939
4040
import static com.scrivenvar.Constants.APP_TITLE;
41
import static com.scrivenvar.Constants.STATUS_BAR_DEFAULT;
41
import static com.scrivenvar.Constants.STATUS_BAR_OK;
42
import static com.scrivenvar.Messages.get;
4243
import static javafx.scene.control.Alert.AlertType.CONFIRMATION;
4344
import static javafx.scene.control.Alert.AlertType.ERROR;
...
5051
public final class DefaultNotifier extends Observable implements Notifier {
5152
52
  public DefaultNotifier() {
53
  }
53
  private final static String OK = get( STATUS_BAR_OK, "OK" );
5454
5555
  /**
...
6868
  @Override
6969
  public void clear() {
70
    notify( STATUS_BAR_DEFAULT );
70
    notify( OK );
7171
  }
7272
M src/main/java/com/scrivenvar/util/StageState.java
2828
2929
import java.util.prefs.Preferences;
30
3031
import javafx.application.Platform;
3132
import javafx.scene.shape.Rectangle;
...
4445
  public static final String K_PANE_SPLIT_PREVIEW = "pane.split.preview";
4546
46
  private final Stage stage;
47
  private final Preferences state;
47
  private final Stage mStage;
48
  private final Preferences mState;
4849
4950
  private Rectangle normalBounds;
5051
  private boolean runLaterPending;
5152
5253
  public StageState( final Stage stage, final Preferences state ) {
53
    this.stage = stage;
54
    this.state = state;
54
    mStage = stage;
55
    mState = state;
5556
5657
    restore();
5758
5859
    stage.addEventHandler( WindowEvent.WINDOW_HIDING, e -> save() );
5960
60
    stage.xProperty().addListener( (ob, o, n) -> boundsChanged() );
61
    stage.yProperty().addListener( (ob, o, n) -> boundsChanged() );
62
    stage.widthProperty().addListener( (ob, o, n) -> boundsChanged() );
63
    stage.heightProperty().addListener( (ob, o, n) -> boundsChanged() );
61
    stage.xProperty().addListener( ( ob, o, n ) -> boundsChanged() );
62
    stage.yProperty().addListener( ( ob, o, n ) -> boundsChanged() );
63
    stage.widthProperty().addListener( ( ob, o, n ) -> boundsChanged() );
64
    stage.heightProperty().addListener( ( ob, o, n ) -> boundsChanged() );
6465
  }
6566
6667
  private void save() {
6768
    final Rectangle bounds = isNormalState() ? getStageBounds() : normalBounds;
68
    
69
6970
    if( bounds != null ) {
70
      state.putDouble( "windowX", bounds.getX() );
71
      state.putDouble( "windowY", bounds.getY() );
72
      state.putDouble( "windowWidth", bounds.getWidth() );
73
      state.putDouble( "windowHeight", bounds.getHeight() );
71
      mState.putDouble( "windowX", bounds.getX() );
72
      mState.putDouble( "windowY", bounds.getY() );
73
      mState.putDouble( "windowWidth", bounds.getWidth() );
74
      mState.putDouble( "windowHeight", bounds.getHeight() );
7475
    }
75
    
76
    state.putBoolean( "windowMaximized", stage.isMaximized() );
77
    state.putBoolean( "windowFullScreen", stage.isFullScreen() );
76
77
    mState.putBoolean( "windowMaximized", mStage.isMaximized() );
78
    mState.putBoolean( "windowFullScreen", mStage.isFullScreen() );
7879
  }
7980
8081
  private void restore() {
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 );
82
    final double x = mState.getDouble( "windowX", Double.NaN );
83
    final double y = mState.getDouble( "windowY", Double.NaN );
84
    final double w = mState.getDouble( "windowWidth", Double.NaN );
85
    final double h = mState.getDouble( "windowHeight", Double.NaN );
86
    final boolean maximized = mState.getBoolean( "windowMaximized", false );
87
    final boolean fullScreen = mState.getBoolean( "windowFullScreen", false );
8788
8889
    if( !Double.isNaN( x ) && !Double.isNaN( y ) ) {
89
      stage.setX( x );
90
      stage.setY( y );
90
      mStage.setX( x );
91
      mStage.setY( y );
9192
    } // else: default behavior is center on screen
9293
9394
    if( !Double.isNaN( w ) && !Double.isNaN( h ) ) {
94
      stage.setWidth( w );
95
      stage.setHeight( h );
95
      mStage.setWidth( w );
96
      mStage.setHeight( h );
9697
    } // else: default behavior is use scene size
9798
98
    if( fullScreen != stage.isFullScreen() ) {
99
      stage.setFullScreen( fullScreen );
99
    if( fullScreen != mStage.isFullScreen() ) {
100
      mStage.setFullScreen( fullScreen );
100101
    }
101
    
102
    if( maximized != stage.isMaximized() ) {
103
      stage.setMaximized( maximized );
102
103
    if( maximized != mStage.isMaximized() ) {
104
      mStage.setMaximized( maximized );
104105
    }
105106
  }
...
114115
      return;
115116
    }
116
    
117
117118
    runLaterPending = true;
118119
...
130131
131132
  private boolean isNormalState() {
132
    return !stage.isIconified() && !stage.isMaximized() && !stage.isFullScreen();
133
    return !mStage.isIconified() &&
134
        !mStage.isMaximized() &&
135
        !mStage.isFullScreen();
133136
  }
134137
135138
  private Rectangle getStageBounds() {
136
    return new Rectangle( stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight() );
139
    return new Rectangle(
140
        mStage.getX(),
141
        mStage.getY(),
142
        mStage.getWidth(),
143
        mStage.getHeight()
144
    );
137145
  }
138146
}
M src/main/resources/com/scrivenvar/messages.properties
1
#
2
# Copyright 2017 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
#  * Redistributions of source code must retain the above copyright
10
#    notice, this list of conditions and the following disclaimer.
11
#
12
#  * 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
291
# ########################################################################
30
#
312
# Main Application Window
32
#
333
# ########################################################################
344
...
5222
Main.menu.edit.redo=_Redo
5323
Main.menu.edit.find=_Find
54
Main.menu.edit.find.replace=Re_place
5524
Main.menu.edit.find.next=Find _Next
56
Main.menu.edit.find.previous=Find _Previous
5725
5826
Main.menu.insert=_Insert
...
9159
9260
# ########################################################################
93
#
9461
# Status Bar
95
#
9662
# ########################################################################
9763
9864
Main.statusbar.text.offset=offset
9965
Main.statusbar.line=Line {0} of {1}, ${Main.statusbar.text.offset} {2}
10066
Main.statusbar.state.default=OK
10167
Main.statusbar.parse.error={0} (near ${Main.statusbar.text.offset} {1})
10268
10369
# ########################################################################
104
#
10570
# Definition Pane and its Tree View
106
#
10771
# ########################################################################
10872
109
Definition.menu.add=Add
73
Definition.menu.create=Create
74
Definition.menu.rename=Rename
11075
Definition.menu.remove=Delete
76
Definition.menu.add.default=Undefined
11177
11278
# ########################################################################
113
#
79
# Failure messages with respect to YAML files.
80
# ########################################################################
81
yaml.error.open=Could not open YAML file (ensure non-empty file).
82
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
83
yaml.error.missing=Empty definition value for key ''{0}''.
84
yaml.error.tree.form=Unassigned definition near ''{0}''.
85
86
# ########################################################################
11487
# File Editor
115
#
11688
# ########################################################################
11789
118
FileEditor.untitled=Untitled
11990
FileEditor.loadFailed.message=Failed to load ''{0}''.\n\nReason: {1}
12091
FileEditor.loadFailed.title=Load
92
FileEditor.loadFailed.reason.permissions=File must be readable and writable.
12193
FileEditor.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
12294
FileEditor.saveFailed.title=Save
12395
12496
# ########################################################################
125
#
12697
# File Open
127
#
12898
# ########################################################################
12999
...
137107
138108
# ########################################################################
139
#
140109
# Alert Dialog
141
#
142110
# ########################################################################
143111
144112
Alert.file.close.title=Close
145113
Alert.file.close.text=Save changes to {0}?
146114
147115
# ########################################################################
148
#
149116
# Definition Pane
150
#
151117
# ########################################################################
152118
153119
Pane.definition.node.root.title=Definitions
154120
155121
# Controls ###############################################################
156122
157123
# ########################################################################
158
#
159124
# Browse Directory
160
#
161125
# ########################################################################
162126
163127
BrowseDirectoryButton.chooser.title=Browse for local folder
164128
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
165129
166130
# ########################################################################
167
#
168131
# Browse File
169
#
170132
# ########################################################################
171133
172134
BrowseFileButton.chooser.title=Browse for local file
173135
BrowseFileButton.chooser.allFilesFilter=All Files
174136
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
175137
176138
# Dialogs ################################################################
177139
178140
# ########################################################################
179
#
180141
# Image
181
#
182142
# ########################################################################
183143
...
190150
191151
# ########################################################################
192
#
193152
# Hyperlink
194
#
195153
# ########################################################################
196154
197155
Dialog.link.title=Link
198156
Dialog.link.previewLabel.text=Markdown Preview\:
199157
Dialog.link.textLabel.text=Link Text\:
200158
Dialog.link.titleLabel.text=Title (tooltip)\:
201159
Dialog.link.urlLabel.text=Link URL\:
202160
203161
# ########################################################################
204
#
205162
# About
206
#
207163
# ########################################################################
208164
209165
Dialog.about.title=About
210166
Dialog.about.header=${Main.title}
211167
Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber
212168
213169
# R ################################################################
214170
215171
# ########################################################################
216
#
217172
# R Script
218
#
219173
# ########################################################################
220174
221175
Dialog.r.script.title=R Startup Script
222176
Dialog.r.script.content=Provide R statements to run prior to interpreting R statements embedded in the document.
223177
224178
# ########################################################################
225
#
226179
# R Directory
227
#
228180
# ########################################################################
229181
230182
Dialog.r.directory.title=Bootstrap Working Directory
231183
Dialog.r.directory.header=Value for $application.r.working.directory$.
232184
233185
# Options ################################################################
234186
235187
# ########################################################################
236
#
237188
# Options Dialog
238
#
239189
# ########################################################################
240190
241191
OptionsDialog.title=Options
242192
OptionsDialog.generalTab.text=General
243193
OptionsDialog.markdownTab.text=Markdown
244194
245195
# ########################################################################
246
#
247196
# General Options Pane
248
#
249197
# ########################################################################
250198
...
258206
259207
# ########################################################################
260
#
261208
# Markdown Options Pane
262
#
263209
# ########################################################################
264210
M src/main/resources/com/scrivenvar/settings.properties
4949
# reference can be inserted.
5050
file.default=untitled.md
51
file.definition.default=variables.yaml
5152
5253
# ########################################################################
A src/test/java/com/scrivenvar/definition/TreeItemInterpolatorTest.java
1
/*
2
 * Copyright 2020 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 javafx.scene.control.TreeItem;
31
import org.junit.jupiter.api.Test;
32
33
import static java.lang.String.format;
34
import static org.junit.jupiter.api.Assertions.assertEquals;
35
36
public class TreeItemInterpolatorTest {
37
38
  private final static String AUTHOR_FIRST = "FirstName";
39
  private final static String AUTHOR_LAST = "LastName";
40
  private final static String AUTHOR_ALL = "$root.name.first$ $root.name.last$";
41
42
  /**
43
   * Test that a hierarchical relationship of {@link TreeItem} instances can
44
   * create a flat map with all string values containing key names interpolated.
45
   */
46
  @Test
47
  public void test_Resolve_ReferencesInTree_InterpolatedMap() {
48
    final var root = new TreeItem<>( "root" );
49
    final var name = new TreeItem<>( "name" );
50
    final var first = new TreeItem<>( "first" );
51
    final var authorFirst = new TreeItem<>( AUTHOR_FIRST );
52
    final var last = new TreeItem<>( "last" );
53
    final var authorLast = new TreeItem<>( AUTHOR_LAST );
54
    final var full = new TreeItem<>( "full" );
55
    final var expr = new TreeItem<>( AUTHOR_ALL );
56
57
    root.getChildren().add( name );
58
    name.getChildren().add( first );
59
    name.getChildren().add( last );
60
    name.getChildren().add( full );
61
62
    first.getChildren().add( authorFirst );
63
    last.getChildren().add( authorLast );
64
    full.getChildren().add( expr );
65
66
    final var map = TreeItemAdapter.toMap( root );
67
68
    var actualAuthor = map.get( "$root.name.full$" );
69
    var expectedAuthor = AUTHOR_ALL;
70
    assertEquals( expectedAuthor, actualAuthor );
71
72
    MapInterpolator.interpolate( map );
73
    actualAuthor = map.get( "$root.name.full$" );
74
75
    expectedAuthor = format( "%s %s", AUTHOR_FIRST, AUTHOR_LAST );
76
    assertEquals( expectedAuthor, actualAuthor );
77
  }
78
}
179