Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M LICENSE
1
Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
1
Copyright 2017 White Magic Software, Ltd.
22
All rights reserved.
33
A R/README.md
1
R Functions
2
===
3
4
Import the files in this directory into the application, which include:
5
6
* pluralise.R
7
* possessive.R
8
9
pluralise.R
10
===
11
12
This file defines a function that implements most of Damian Conway's [An Algorithmic Approach to English Pluralization](http://blob.perl.org/tpc/1998/User_Applications/Algorithmic%20Approach%20Plurals/Algorithmic_Plurals.html).
13
14
Usage
15
---
16
Example usages of the pluralise function include:
17
18
    `r#pluralise( 'mouse' )` - mice
19
    `r#pluralise( 'buzz' )` - buzzes
20
    `r#pluralise( 'bus' )` - busses
21
22
possessive.R
23
===
24
25
This file defines a function that applies possessives to English words.
26
27
Usage
28
---
29
Example usages of the possessive function include:
30
31
    `r#pos( 'Ross' )` - Ross'
32
    `r#pos( 'Ruby' )` - Ruby's
33
    `r#pos( 'Lois' )` - Lois'
34
135
A R/pluralise.R
1
# ######################################################################
2
#
3
# Copyright 2016, White Magic Software, Ltd.
4
# 
5
# Permission is hereby granted, free of charge, to any person obtaining
6
# a copy of this software and associated documentation files (the
7
# "Software"), to deal in the Software without restriction, including
8
# without limitation the rights to use, copy, modify, merge, publish,
9
# distribute, sublicense, and/or sell copies of the Software, and to
10
# permit persons to whom the Software is furnished to do so, subject to
11
# the following conditions:
12
# 
13
# The above copyright notice and this permission notice shall be
14
# included in all copies or substantial portions of the Software.
15
# 
16
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
#
24
# ######################################################################
25
26
# ######################################################################
27
#
28
# See Damian Conway's "An Algorithmic Approach to English Pluralization":
29
#   http://goo.gl/oRL4MP
30
# See Oliver Glerke's Evo Inflector: https://github.com/atteo/evo-inflector/
31
# See Shevek's Pluralizer: https://github.com/shevek/linguistics/
32
# See also: http://www.freevectors.net/assets/files/plural.txt
33
#
34
# ######################################################################
35
36
pluralise <- function( s, n ) {
37
  result <- s
38
39
  # Partial implementation of Conway's algorithm for nouns.
40
  if( n != 1 ) {
41
    if( pl.noninflective( s ) ||
42
				pl.suffix( "es", s ) ||
43
        pl.suffix( "fish", s ) ||
44
        pl.suffix( "ois", s ) ||
45
        pl.suffix( "sheep", s ) ||
46
        pl.suffix( "deer", s ) ||
47
        pl.suffix( "pox", s ) ||
48
        pl.suffix( "[A-Z].*ese", s ) ||
49
        pl.suffix( "itis", s ) ) {
50
      # 1. Retain non-inflective user-mapped noun as is.
51
      # 2. Retain non-inflective plural as is.
52
      result <- s
53
    }
54
    else if( pl.is.irregular.pl( s ) ) {
55
      # 4. Change irregular plurals based on mapping.
56
      result <- pl.irregular.pl( s )
57
    }
58
    else if( pl.is.irregular.es( s ) ) {
59
      # x. From Shevek's
60
      result <- pl.inflect( s, "", "es" )
61
    }
62
    else if( pl.suffix( "man", s ) ) {
63
      # 5. For -man, change -an to -en
64
      result <- pl.inflect( s, "an", "en" )
65
    }
66
    else if( pl.suffix( "[lm]ouse", s ) ) {
67
      # 5. For [lm]ouse, change -ouse to -ice
68
      result <- pl.inflect( s, "ouse", "ice" )
69
    }
70
    else if( pl.suffix( "tooth", s ) ) {
71
      # 5. For -tooth, change -ooth to -eeth
72
      result <- pl.inflect( s, "ooth", "eeth" )
73
    }
74
    else if( pl.suffix( "goose", s ) ) {
75
      # 5. For -goose, change -oose to -eese
76
      result <- pl.inflect( s, "oose", "eese" )
77
    }
78
    else if( pl.suffix( "foot", s ) ) {
79
      # 5. For -foot, change -oot to -eet
80
      result <- pl.inflect( s, "oot", "eet" )
81
    }
82
    else if( pl.suffix( "zoon", s ) ) {
83
      # 5. For -zoon, change -on to -a
84
      result <- pl.inflect( s, "on", "a" )
85
    }
86
    else if( pl.suffix( "[csx]is", s ) ) {
87
      # 5. Change -cis, -sis, -xis to -es
88
      result <- pl.inflect( s, "is", "es" )
89
    }
90
    else if( pl.suffix( "([cs]h|ss|zz|x|s)", s ) ) {
91
      # 8. Change -ch, -sh, -ss, -zz, -x, -s to -es
92
      result <- pl.inflect( s, "", "es" )
93
    }
94
    else if( pl.suffix( "([aeo]lf|[^d]eaf|arf)", s ) ) {
95
      # 9. Change -f to -ves
96
      result <- pl.inflect( s, "f", "ves" )
97
    }
98
    else if( pl.suffix( "[nlw]ife", s ) ) {
99
      # 10. Change -fe to -ves
100
      result <- pl.inflect( s, "fe", "ves" )
101
    }
102
    else if( pl.suffix( "[aeiou]y", s ) ) {
103
      # 11. Change -[aeiou]y to -ys
104
      result <- pl.inflect( s, "", "s" )
105
    }
106
    else if( pl.suffix( "y", s ) ) {
107
      # 12. Change -y to -ies
108
      result <- pl.inflect( s, "y", "ies" )
109
    }
110
    else if( pl.suffix( "z", s ) ) {
111
      # x. Change -z to -zzes
112
      result <- pl.inflect( s, "", "zes" )
113
    }
114
    else {
115
      # 13. Default plural: add -s
116
      result <- pl.inflect( s, "", "s" )
117
    }
118
  }
119
120
  result
121
}
122
123
# Returns the given string (s) with its suffix replaced by r.
124
pl.inflect <- function( s, suffix, r ) {
125
  gsub( paste( suffix, "$", sep="" ), r, s )
126
}
127
128
# Answers whether the given string (s) has the given ending.
129
pl.suffix <- function( ending, s ) {
130
  grepl( paste( ending, "$", sep="" ), s )
131
}
132
133
# Answers whether the given string (s) is a noninflective noun.
134
pl.noninflective <- function( s ) {
135
  v <- c(
136
		"aircraft",
137
		"Bhutanese",
138
		"bison",
139
		"bream",
140
		"Burmese",
141
		"carp",
142
		"chassis",
143
		"Chinese",
144
		"clippers",
145
		"cod",
146
		"contretemps",
147
		"corps",
148
		"debris",
149
		"djinn",
150
		"eland",
151
		"elk",
152
		"flounder",
153
		"fracas",
154
		"gallows",
155
		"graffiti",
156
		"headquarters",
157
		"high-jinks",
158
		"homework",
159
		"hovercraft",
160
		"innings",
161
		"Japanese",
162
		"Lebanese",
163
		"mackerel",
164
		"means",
165
		"mews",
166
		"mice",
167
		"mumps",
168
		"news",
169
		"pincers",
170
		"pliers",
171
		"Portuguese",
172
		"proceedings",
173
		"salmon",
174
		"scissors",
175
		"sea-bass",
176
		"Senegalese",
177
		"shears",
178
		"Siamese",
179
		"Sinhalese",
180
		"spacecraft",
181
		"swine",
182
		"trout",
183
		"tuna",
184
		"Vietnamese",
185
		"watercraft",
186
		"whiting",
187
		"wildebeest"
188
  )
189
190
  is.element( s, v )
191
}
192
193
# Answers whether the given string (s) is an irregular plural.
194
pl.is.irregular.pl <- function( s ) {
195
  # Could be refactored with pl.irregular.pl...
196
  v <- c(
197
    "beef", "brother", "child", "cow", "ephemeris", "genie", "money",
198
    "mongoose", "mythos", "octopus", "ox", "soliloquy", "trilby"
199
  )
200
201
  is.element( s, v )
202
}
203
204
# Call to pluralise an irregular noun. Only call after confirming
205
# the noun is irregular via pl.is.irregular.pl.
206
pl.irregular.pl <- function( s ) {
207
  v <- list(
208
    "beef" = "beefs",
209
    "brother" = "brothers",
210
    "child" = "children",
211
    "cow" = "cows",
212
    "ephemeris" = "ephemerides",
213
    "genie" = "genies",
214
    "money" = "moneys",
215
    "mongoose" = "mongooses",
216
    "mythos" = "mythoi",
217
    "octopus" = "octopuses",
218
    "ox" = "oxen",
219
    "soliloquy" = "soliloquies",
220
    "trilby" = "trilbys"
221
  )
222
223
  # Faster version of v[[ s ]]
224
  .subset2( v, s )
225
}
226
227
# Answers whether the given string (s) pluralises with -es.
228
pl.is.irregular.es <- function( s ) {
229
  v <- c(
230
    "acropolis", "aegis", "alias", "asbestos", "bathos", "bias", "bronchitis",
231
    "bursitis", "caddis", "cannabis", "canvas", "chaos", "cosmos", "dais",
232
    "digitalis", "epidermis", "ethos", "eyas", "gas", "glottis", "hubris",
233
    "ibis", "lens", "mantis", "marquis", "metropolis", "pathos", "pelvis",
234
    "polis", "rhinoceros", "sassafrass", "trellis"
235
  )
236
237
  is.element( s, v )
238
}
239
1240
A R/possessive.R
1
# ######################################################################
2
#
3
# Copyright 2017, White Magic Software, Ltd.
4
# 
5
# Permission is hereby granted, free of charge, to any person obtaining
6
# a copy of this software and associated documentation files (the
7
# "Software"), to deal in the Software without restriction, including
8
# without limitation the rights to use, copy, modify, merge, publish,
9
# distribute, sublicense, and/or sell copies of the Software, and to
10
# permit persons to whom the Software is furnished to do so, subject to
11
# the following conditions:
12
# 
13
# The above copyright notice and this permission notice shall be
14
# included in all copies or substantial portions of the Software.
15
# 
16
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
#
24
# ######################################################################
25
26
# ######################################################################
27
#
28
# Changes a word into its possessive form.
29
#
30
# ######################################################################
31
32
# Returns leftmost n characters of s.
33
lstr <- function( s, n = 1 ) {
34
  substr( s, 0, n )
35
}
36
37
# Returns rightmost n characters of s.
38
rstr <- function( s, n = 1 ) {
39
  l = nchar( s )
40
  substr( s, l - n + 1, l )
41
}
42
43
# Returns the possessive form of the given word.
44
pos <- function( s ) {
45
  result <- s
46
47
  # Check to see if the last character is an s.
48
  ch <- rstr( s, 1 )
49
50
  if( ch != "s" ) {
51
    result <- concat( result, "'s" )
52
  }
53
  else {
54
    result <- concat( result, "'" )
55
  }
56
57
  result
58
}
59
160
M USAGE.md
11
# Introduction
22
3
This document describes how to write documentation (technical or otherwise) using a master copy for generating a variety of output formats, such as: HTML pages, PDFs, and EPUBs. What's more, the document provides an overview of how to use variables and--for the unintimidated--leverage the power of R, a programming language.
3
This document describes how to use the application.
44
5
# Software Requirements
5
# Variable Definitions
66
7
Install Java, ConTeXt, Pandoc, R, and Lib V8. Then install the R packages knitr, yaml, and devtools, and pluralize by running the following commands:
7
Variable definitions provide a way to insert key names having associated values into a document. The variable names and values are declared inside an external file using the [YAML](http://www.yaml.org/) file format. Simply put, variables are written in the file as follows:
88
9
    sudo su -
10
    apt-get install default-jre
11
    apt-get install context
12
    apt-get install pandoc
13
    apt-get install r
14
    apt-get install libv8-dev
15
    r
16
    url <- "http://cran.us.r-project.org"
17
    install.packages('knitr', repos=url)
18
    install.packages('yaml', repos=url)
19
    install.packages('devtools', repos=url)
20
    devtools::install_github("hrbrmstr/pluralize")
9
```
10
key: value
11
```
2112
22
To exit R, press `Ctrl+d` or type `q()` followed by pressing `Enter`.
13
Any number of variables can be defined, in any order:
2314
24
The required software packages are installed.
15
```
16
key_1: Value 1
17
key_2: Value 2
18
```
2519
26
# Markdown
20
Variables can reference other variables by enclosing the key name within dollar symbols:
2721
28
|Table|Table|
29
|---|---|
30
|Data|Data|
22
```
23
key: Value
24
key_1: $key$ 1
25
key_2: $key$ 2
26
```
27
28
Variables can use a nested structure to help group related information:
29
30
```
31
novel:
32
  title: Book Title
33
  author: Author Name
34
  isbn: 978-3-16-148410-0
35
```
36
37
Use a period to reference nested keys, such as:
38
39
```
40
novel:
41
  author: Author Name
42
copyright:
43
  owner: $novel.author$
44
```
45
46
Save the variable definitions in a file having an extension of `.yaml` or `.yml`.
47
48
# Document Editing
49
50
The application's purpose is to completely separate the document's content from its presentation. To achieve this, documents are composed using a [plain text](http://spec.commonmark.org/0.28/) format.
51
52
## Create Document
53
54
Start a new document as follows:
55
56
1. Start the application.
57
1. Click **File → New** to create an empty document to edit.
58
1. Click **File → Open** to open a variable definition file.
59
1. Change **Source Files** to **Definition Files** to list definition files.
60
1. Browse to and select a file saved with a `.yaml` or `.yml` extension.
61
1. Click **Open**.
62
63
The variable definitions appear in the variable definition pane under the heading of **Definitions**.
64
65
## Edit Document
66
67
Edit the document as normal. Notice how the preview pane updates as new content is added. The toolbar shows various icons that perform different formatting operations. Try them to see how they appear in the preview pane. Other operations not shown on the toolbar include:
68
69
* Struck text (enclose the words within `~~` and `~~`)
70
* Horizontal rule (use `---` on an otherwise empty line).
71
72
The preview pane shows one way to interpret and format the document, but many other presentations are possible.
73
74
## Insert Variable
75
76
Let's assume that the variable definitions loaded into the application include:
77
78
```
79
novel:
80
  title: Diary of $novel.author$
81
  author: Anne Frank
82
```
83
84
To reference a variable, type in the key name enclosed within dollar symbols, such as:
85
86
```
87
The novel "$novel.title$" is one of the most widely read books in the world.
88
```
89
90
The preview pane shows:
91
92
> The novel "Diary of Anne Frank" is one of the most widely read books in the world.
93
94
As it is laborious to type in variable names, it is possible to inject the variable name using autocomplete. Accomplish this as follows:
95
96
1. Create a new file.
97
1. Type in a partial variable value, such as **Dia**.
98
1. Press `Ctrl+Space` (hold down the `Control` key and tap the spacebar).
99
100
The editor shows:
101
102
```
103
$novel.title$
104
```
105
106
The preview pane shows:
107
108
```
109
Diary of Anne Frank
110
```
111
112
The variable name is inserted into the document and the preview pane shows the variable's value.
113
31114
M build.gradle
1616
1717
dependencies {
18
  compile 'org.controlsfx:controlsfx:8.40.12'
18
  compile 'org.controlsfx:controlsfx:8.40.14'
1919
  compile 'org.fxmisc.wellbehaved:wellbehavedfx:0.3'
2020
  compile 'org.fxmisc.richtext:richtextfx:0.8.1'
2121
  compile 'com.miglayout:miglayout-javafx:5.0'
2222
  compile 'de.jensd:fontawesomefx-fontawesome:4.5.0'
2323
  compile 'org.ahocorasick:ahocorasick:0.4.0'
24
  compile 'com.vladsch.flexmark:flexmark:0.28.2'
25
  compile 'com.vladsch.flexmark:flexmark-ext-tables:0.28.2'
26
  compile 'com.vladsch.flexmark:flexmark-ext-superscript:0.28.2'
27
  compile 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.28.2'
28
  compile 'com.fasterxml.jackson.core:jackson-core:2.9.2'
29
  compile 'com.fasterxml.jackson.core:jackson-databind:2.9.2'
30
  compile 'com.fasterxml.jackson.core:jackson-annotations:2.9.2'
31
  compile 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.2'
24
  compile 'com.vladsch.flexmark:flexmark:0.28.24'
25
  compile 'com.vladsch.flexmark:flexmark-ext-tables:0.28.24'
26
  compile 'com.vladsch.flexmark:flexmark-ext-superscript:0.28.24'
27
  compile 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.28.24'
28
  compile 'com.fasterxml.jackson.core:jackson-core:2.9.3'
29
  compile 'com.fasterxml.jackson.core:jackson-databind:2.9.3'
30
  compile 'com.fasterxml.jackson.core:jackson-annotations:2.9.3'
31
  compile 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.3'
3232
  compile 'org.yaml:snakeyaml:1.19'
3333
  compile 'com.ximpleware:vtd-xml:2.13.4'
34
  compile 'net.sf.saxon:Saxon-HE:9.8.0-6'
34
  compile 'net.sf.saxon:Saxon-HE:9.8.0-7'
3535
  compile 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
3636
  compile 'org.apache.commons:commons-configuration2:2.2'
37
  compile files('libs/renjin-script-engine-0.8.2514-jar-with-dependencies.jar')
37
  compile files('libs/renjin-script-engine-0.8.2562-jar-with-dependencies.jar')
3838
}
3939
40
version = '1.3.2'
40
version = '1.3.3'
4141
applicationName = 'scrivenvar'
4242
mainClassName = 'com.scrivenvar.Main'
D libs/renjin-script-engine-0.8.2514-jar-with-dependencies.jar
Binary file
A libs/renjin-script-engine-0.8.2562-jar-with-dependencies.jar
Binary file
A licenses/MARKDOWN-WRITER-FX
1
Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
2
All rights reserved.
3
4
Redistribution and use in source and binary forms, with or without
5
modification, are permitted provided that the following conditions are met:
6
7
* Redistributions of source code must retain the above copyright
8
  notice, this list of conditions and the following disclaimer.
9
10
* Redistributions in binary form must reproduce the above copyright
11
  notice, this list of conditions and the following disclaimer in the
12
  documentation and/or other materials provided with the distribution.
13
14
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
125
M src/main/java/com/scrivenvar/MainWindow.java
119119
  private Map<FileEditorTab, Processor<String>> processors;
120120
121
  public MainWindow() {
122
    initLayout();
123
    initFindInput();
124
    initSnitch();
125
    initDefinitionListener();
126
    initTabAddedListener();
127
    initTabChangedListener();
128
    initPreferences();
129
  }
130
131
  /**
132
   * Watch for changes to external files. In particular, this awaits
133
   * modifications to any XSL files associated with XML files being edited. When
134
   * an XSL file is modified (external to the application), the snitch's ears
135
   * perk up and the file is reloaded. This keeps the XSL transformation up to
136
   * date with what's on the file system.
137
   */
138
  private void initSnitch() {
139
    getSnitch().addObserver( this );
140
  }
141
142
  /**
143
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
144
   * presses.
145
   */
146
  private void initFindInput() {
147
    final TextField input = getFindTextField();
148
149
    input.setOnKeyPressed( (KeyEvent event) -> {
150
      switch( event.getCode() ) {
151
        case F3:
152
        case ENTER:
153
          findNext();
154
          break;
155
        case F:
156
          if( !event.isControlDown() ) {
157
            break;
158
          }
159
        case ESCAPE:
160
          getStatusBar().setGraphic( null );
161
          getActiveFileEditor().getEditorPane().requestFocus();
162
          break;
163
      }
164
    } );
165
166
    // Remove when the input field loses focus.
167
    input.focusedProperty().addListener(
168
      (
169
        final ObservableValue<? extends Boolean> focused,
170
        final Boolean oFocus,
171
        final Boolean nFocus) -> {
172
        if( !nFocus ) {
173
          getStatusBar().setGraphic( null );
174
        }
175
      }
176
    );
177
  }
178
179
  /**
180
   * Listen for file editor tab pane to receive an open definition source event.
181
   */
182
  private void initDefinitionListener() {
183
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
184
      (ObservableValue<? extends Path> definitionFile,
185
        final Path oldPath, final Path newPath) -> {
186
        openDefinition( newPath );
187
188
        // Indirectly refresh the resolved map.
189
        setProcessors( null );
190
        updateDefinitionPane();
191
192
        try {
193
          getSnitch().ignore( oldPath );
194
          getSnitch().listen( newPath );
195
        } catch( final IOException ex ) {
196
          error( ex );
197
        }
198
199
        // Will create new processors and therefore a new resolved map.
200
        refreshSelectedTab( getActiveFileEditor() );
201
      }
202
    );
203
  }
204
205
  /**
206
   * When tabs are added, hook the various change listeners onto the new tab so
207
   * that the preview pane refreshes as necessary.
208
   */
209
  private void initTabAddedListener() {
210
    final FileEditorTabPane editorPane = getFileEditorPane();
211
212
    // Make sure the text processor kicks off when new files are opened.
213
    final ObservableList<Tab> tabs = editorPane.getTabs();
214
215
    // Update the preview pane on tab changes.
216
    tabs.addListener(
217
      (final Change<? extends Tab> change) -> {
218
        while( change.next() ) {
219
          if( change.wasAdded() ) {
220
            // Multiple tabs can be added simultaneously.
221
            for( final Tab newTab : change.getAddedSubList() ) {
222
              final FileEditorTab tab = (FileEditorTab)newTab;
223
224
              initTextChangeListener( tab );
225
              initCaretParagraphListener( tab );
226
              initVariableNameInjector( tab );
227
//              initSyntaxListener( tab );
228
            }
229
          }
230
        }
231
      }
232
    );
233
  }
234
235
  /**
236
   * Reloads the preferences from the previous session.
237
   */
238
  private void initPreferences() {
239
    restoreDefinitionSource();
240
    getFileEditorPane().restorePreferences();
241
    updateDefinitionPane();
242
  }
243
244
  /**
245
   * Listen for new tab selection events.
246
   */
247
  private void initTabChangedListener() {
248
    final FileEditorTabPane editorPane = getFileEditorPane();
249
250
    // Update the preview pane changing tabs.
251
    editorPane.addTabSelectionListener(
252
      (ObservableValue<? extends Tab> tabPane,
253
        final Tab oldTab, final Tab newTab) -> {
254
255
        // If there was no old tab, then this is a first time load, which
256
        // can be ignored.
257
        if( oldTab != null ) {
258
          if( newTab == null ) {
259
            closeRemainingTab();
260
          }
261
          else {
262
            // Update the preview with the edited text.
263
            refreshSelectedTab( (FileEditorTab)newTab );
264
          }
265
        }
266
      }
267
    );
268
  }
269
270
  private void initTextChangeListener( final FileEditorTab tab ) {
271
    tab.addTextChangeListener(
272
      (ObservableValue<? extends String> editor,
273
        final String oldValue, final String newValue) -> {
274
        refreshSelectedTab( tab );
275
      }
276
    );
277
  }
278
279
  private void initCaretParagraphListener( final FileEditorTab tab ) {
280
    tab.addCaretParagraphListener(
281
      (ObservableValue<? extends Integer> editor,
282
        final Integer oldValue, final Integer newValue) -> {
283
        refreshSelectedTab( tab );
284
      }
285
    );
286
  }
287
288
  private void initVariableNameInjector( final FileEditorTab tab ) {
289
    VariableNameInjector.listen( tab, getDefinitionPane() );
290
  }
291
292
  /**
293
   * Called whenever the preview pane becomes out of sync with the file editor
294
   * tab. This can be called when the text changes, the caret paragraph changes,
295
   * or the file tab changes.
296
   *
297
   * @param tab The file editor tab that has been changed in some fashion.
298
   */
299
  private void refreshSelectedTab( final FileEditorTab tab ) {
300
    if( tab.isFileOpen() ) {
301
      getPreviewPane().setPath( tab.getPath() );
302
303
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
304
      final Position p = tab.getCaretOffset();
305
      getLineNumberText().setText(
306
        get( STATUS_BAR_LINE,
307
          p.getMajor() + 1,
308
          p.getMinor() + 1,
309
          tab.getCaretPosition() + 1
310
        )
311
      );
312
313
      Processor<String> processor = getProcessors().get( tab );
314
315
      if( processor == null ) {
316
        processor = createProcessor( tab );
317
        getProcessors().put( tab, processor );
318
      }
319
320
      try {
321
        getNotifier().clear();
322
        processor.processChain( tab.getEditorText() );
323
      } catch( final Exception ex ) {
324
        error( ex );
325
      }
326
    }
327
  }
328
329
  /**
330
   * Used to find text in the active file editor window.
331
   */
332
  private void find() {
333
    final TextField input = getFindTextField();
334
    getStatusBar().setGraphic( input );
335
    input.requestFocus();
336
  }
337
338
  public void findNext() {
339
    getActiveFileEditor().searchNext( getFindTextField().getText() );
340
  }
341
342
  /**
343
   * Returns the variable map of interpolated definitions.
344
   *
345
   * @return A map to help dereference variables.
346
   */
347
  private Map<String, String> getResolvedMap() {
348
    return getDefinitionSource().getResolvedMap();
349
  }
350
351
  /**
352
   * Returns the root node for the hierarchical definition source.
353
   *
354
   * @return Data to display in the definition pane.
355
   */
356
  private TreeView<String> getTreeView() {
357
    try {
358
      return getDefinitionSource().asTreeView();
359
    } catch( Exception e ) {
360
      error( e );
361
    }
362
363
    // Slightly redundant as getDefinitionSource() might have returned an
364
    // empty definition source.
365
    return (new EmptyDefinitionSource()).asTreeView();
366
  }
367
368
  /**
369
   * Called when a definition source is opened.
370
   *
371
   * @param path Path to the definition source that was opened.
372
   */
373
  private void openDefinition( final Path path ) {
374
    try {
375
      final DefinitionSource ds = createDefinitionSource( path.toString() );
376
      setDefinitionSource( ds );
377
      storeDefinitionSource();
378
      updateDefinitionPane();
379
    } catch( final Exception e ) {
380
      error( e );
381
    }
382
  }
383
384
  private void updateDefinitionPane() {
385
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
386
  }
387
388
  private void restoreDefinitionSource() {
389
    final Preferences preferences = getPreferences();
390
    final String source = preferences.get( PERSIST_DEFINITION_SOURCE, null );
391
392
    // If there's no definition source set, don't try to load it.
393
    if( source != null ) {
394
      setDefinitionSource( createDefinitionSource( source ) );
395
    }
396
  }
397
398
  private void storeDefinitionSource() {
399
    final Preferences preferences = getPreferences();
400
    final DefinitionSource ds = getDefinitionSource();
401
402
    preferences.put( PERSIST_DEFINITION_SOURCE, ds.toString() );
403
  }
404
405
  /**
406
   * Called when the last open tab is closed to clear the preview pane.
407
   */
408
  private void closeRemainingTab() {
409
    getPreviewPane().clear();
410
  }
411
412
  /**
413
   * Called when an exception occurs that warrants the user's attention.
414
   *
415
   * @param e The exception with a message that the user should know about.
416
   */
417
  private void error( final Exception e ) {
418
    getNotifier().notify( e );
419
  }
420
421
  //---- File actions -------------------------------------------------------
422
  /**
423
   * Called when an observable instance has changed. This is called by both the
424
   * snitch service and the notify service. The snitch service can be called for
425
   * different file types, including definition sources.
426
   *
427
   * @param observable The observed instance.
428
   * @param value The noteworthy item.
429
   */
430
  @Override
431
  public void update( final Observable observable, final Object value ) {
432
    if( value != null ) {
433
      if( observable instanceof Snitch && value instanceof Path ) {
434
        final Path path = (Path)value;
435
        final FileTypePredicate predicate
436
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
437
438
        // Reload definitions.
439
        if( predicate.test( path.toFile() ) ) {
440
          updateDefinitionSource( path );
441
        }
442
443
        updateSelectedTab();
444
      }
445
      else if( observable instanceof Notifier && value instanceof String ) {
446
        updateStatusBar( (String)value );
447
      }
448
    }
449
  }
450
451
  /**
452
   * Updates the status bar to show the given message.
453
   *
454
   * @param s The message to show in the status bar.
455
   */
456
  private void updateStatusBar( final String s ) {
457
    Platform.runLater(
458
      () -> {
459
        final int index = s.indexOf( '\n' );
460
        final String message = s.substring( 0, index > 0 ? index : s.length() );
461
462
        getStatusBar().setText( message );
463
      }
464
    );
465
  }
466
467
  /**
468
   * Called when a file has been modified.
469
   *
470
   * @param file Path to the modified file.
471
   */
472
  private void updateSelectedTab() {
473
    Platform.runLater(
474
      () -> {
475
        // Brute-force XSLT file reload by re-instantiating all processors.
476
        resetProcessors();
477
        refreshSelectedTab( getActiveFileEditor() );
478
      }
479
    );
480
  }
481
482
  /**
483
   * Reloads the definition source from the given path.
484
   *
485
   * @param path The path containing new definition information.
486
   */
487
  private void updateDefinitionSource( final Path path ) {
488
    Platform.runLater(
489
      () -> {
490
        openDefinition( path );
491
      }
492
    );
493
  }
494
495
  /**
496
   * After resetting the processors, they will refresh anew to be up-to-date
497
   * with the files (text and definition) currently loaded into the editor.
498
   */
499
  private void resetProcessors() {
500
    getProcessors().clear();
501
  }
502
503
  //---- File actions -------------------------------------------------------
504
  private void fileNew() {
505
    getFileEditorPane().newEditor();
506
  }
507
508
  private void fileOpen() {
509
    getFileEditorPane().openFileDialog();
510
  }
511
512
  private void fileClose() {
513
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
514
  }
515
516
  private void fileCloseAll() {
517
    getFileEditorPane().closeAllEditors();
518
  }
519
520
  private void fileSave() {
521
    getFileEditorPane().saveEditor( getActiveFileEditor() );
522
  }
523
524
  private void fileSaveAs() {
525
    final FileEditorTab editor = getActiveFileEditor();
526
    getFileEditorPane().saveEditorAs( editor );
527
    getProcessors().remove( editor );
528
529
    try {
530
      refreshSelectedTab( editor );
531
    } catch( final Exception ex ) {
532
      getNotifier().notify( ex );
533
    }
534
  }
535
536
  private void fileSaveAll() {
537
    getFileEditorPane().saveAllEditors();
538
  }
539
540
  private void fileExit() {
541
    final Window window = getWindow();
542
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
543
  }
544
545
  //---- Tools actions
546
  private void toolsScript() {
547
    final String script = getStartupScript();
548
549
    final RScriptDialog dialog = new RScriptDialog(
550
      getWindow(), "Dialog.rScript.title", script );
551
    final Optional<String> result = dialog.showAndWait();
552
553
    result.ifPresent( (String s) -> {
554
      putStartupScript( s );
555
    } );
556
  }
557
558
  /**
559
   * Gets the R startup script from the user preferences.
560
   */
561
  private String getStartupScript() {
562
    return getPreferences().get( PERSIST_R_STARTUP, "" );
563
  }
564
565
  /**
566
   * Puts an R startup script into the user preferences.
567
   */
568
  private void putStartupScript( final String s ) {
569
    try {
570
      getPreferences().put( PERSIST_R_STARTUP, s );
571
    } catch( final Exception ex ) {
572
      getNotifier().notify( ex );
573
    }
574
  }
575
576
  //---- Help actions -------------------------------------------------------
577
  private void helpAbout() {
578
    Alert alert = new Alert( AlertType.INFORMATION );
579
    alert.setTitle( get( "Dialog.about.title" ) );
580
    alert.setHeaderText( get( "Dialog.about.header" ) );
581
    alert.setContentText( get( "Dialog.about.content" ) );
582
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
583
    alert.initOwner( getWindow() );
584
585
    alert.showAndWait();
586
  }
587
588
  //---- Convenience accessors ----------------------------------------------
589
  private float getFloat( final String key, final float defaultValue ) {
590
    return getPreferences().getFloat( key, defaultValue );
591
  }
592
593
  private Preferences getPreferences() {
594
    return getOptions().getState();
595
  }
596
597
  protected Scene getScene() {
598
    if( this.scene == null ) {
599
      this.scene = createScene();
600
    }
601
602
    return this.scene;
603
  }
604
605
  public Window getWindow() {
606
    return getScene().getWindow();
607
  }
608
609
  private MarkdownEditorPane getActiveEditor() {
610
    final EditorPane pane = getActiveFileEditor().getEditorPane();
611
612
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
613
  }
614
615
  private FileEditorTab getActiveFileEditor() {
616
    return getFileEditorPane().getActiveFileEditor();
617
  }
618
619
  //---- Member accessors ---------------------------------------------------
620
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
621
    this.processors = map;
622
  }
623
624
  private Map<FileEditorTab, Processor<String>> getProcessors() {
625
    if( this.processors == null ) {
626
      setProcessors( new HashMap<>() );
627
    }
628
629
    return this.processors;
630
  }
631
632
  private FileEditorTabPane getFileEditorPane() {
633
    if( this.fileEditorPane == null ) {
634
      this.fileEditorPane = createFileEditorPane();
635
    }
636
637
    return this.fileEditorPane;
638
  }
639
640
  private HTMLPreviewPane getPreviewPane() {
641
    if( this.previewPane == null ) {
642
      this.previewPane = createPreviewPane();
643
    }
644
645
    return this.previewPane;
646
  }
647
648
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
649
    this.definitionSource = definitionSource;
650
  }
651
652
  private DefinitionSource getDefinitionSource() {
653
    if( this.definitionSource == null ) {
654
      this.definitionSource = new EmptyDefinitionSource();
655
    }
656
657
    return this.definitionSource;
658
  }
659
660
  private DefinitionPane getDefinitionPane() {
661
    if( this.definitionPane == null ) {
662
      this.definitionPane = createDefinitionPane();
663
    }
664
665
    return this.definitionPane;
666
  }
667
668
  private Options getOptions() {
669
    return this.options;
670
  }
671
672
  private Snitch getSnitch() {
673
    return this.snitch;
674
  }
675
676
  private Notifier getNotifier() {
677
    return this.notifier;
678
  }
679
680
  public void setMenuBar( final MenuBar menuBar ) {
681
    this.menuBar = menuBar;
682
  }
683
684
  public MenuBar getMenuBar() {
685
    return this.menuBar;
686
  }
687
688
  private Text getLineNumberText() {
689
    if( this.lineNumberText == null ) {
690
      this.lineNumberText = createLineNumberText();
691
    }
692
693
    return this.lineNumberText;
694
  }
695
696
  private synchronized StatusBar getStatusBar() {
697
    if( this.statusBar == null ) {
698
      this.statusBar = createStatusBar();
699
    }
700
701
    return this.statusBar;
702
  }
703
704
  private TextField getFindTextField() {
705
    if( this.findTextField == null ) {
706
      this.findTextField = createFindTextField();
707
    }
708
709
    return this.findTextField;
710
  }
711
712
  //---- Member creators ----------------------------------------------------
713
  /**
714
   * Factory to create processors that are suited to different file types.
715
   *
716
   * @param tab The tab that is subjected to processing.
717
   *
718
   * @return A processor suited to the file type specified by the tab's path.
719
   */
720
  private Processor<String> createProcessor( final FileEditorTab tab ) {
721
    return createProcessorFactory().createProcessor( tab );
722
  }
723
724
  private ProcessorFactory createProcessorFactory() {
725
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
726
  }
727
728
  private DefinitionSource createDefinitionSource( final String path ) {
729
    DefinitionSource ds;
730
731
    try {
732
       ds = createDefinitionFactory().createDefinitionSource( path );
733
734
      if( ds instanceof FileDefinitionSource ) {
735
        try {
736
          getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
737
        } catch( final IOException ex ) {
738
          error( ex );
739
        }
740
      }
741
    } catch( final Exception ex ) {
742
      ds = new EmptyDefinitionSource();
743
      error( ex );
744
    }
745
746
    return ds;
747
  }
748
749
  private TextField createFindTextField() {
750
    return new TextField();
751
  }
752
753
  /**
754
   * Create an editor pane to hold file editor tabs.
755
   *
756
   * @return A new instance, never null.
757
   */
758
  private FileEditorTabPane createFileEditorPane() {
759
    return new FileEditorTabPane();
760
  }
761
762
  private HTMLPreviewPane createPreviewPane() {
763
    return new HTMLPreviewPane();
764
  }
765
766
  private DefinitionPane createDefinitionPane() {
767
    return new DefinitionPane( getTreeView() );
768
  }
769
770
  private DefinitionFactory createDefinitionFactory() {
771
    return new DefinitionFactory();
772
  }
773
774
  private StatusBar createStatusBar() {
775
    return new StatusBar();
776
  }
777
778
  private Scene createScene() {
779
    final SplitPane splitPane = new SplitPane(
780
      getDefinitionPane().getNode(),
781
      getFileEditorPane().getNode(),
782
      getPreviewPane().getNode() );
783
784
    splitPane.setDividerPositions(
785
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
786
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
787
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
788
789
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
790
    final BorderPane borderPane = new BorderPane();
791
    borderPane.setPrefSize( 1024, 800 );
792
    borderPane.setTop( createMenuBar() );
793
    borderPane.setBottom( getStatusBar() );
794
    borderPane.setCenter( splitPane );
795
796
    final VBox box = new VBox();
797
    box.setAlignment( Pos.BASELINE_CENTER );
798
    box.getChildren().add( getLineNumberText() );
799
    getStatusBar().getRightItems().add( box );
800
801
    return new Scene( borderPane );
802
  }
803
804
  private Text createLineNumberText() {
805
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
806
  }
807
808
  private Node createMenuBar() {
809
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
810
811
    // File actions
812
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ), "Shortcut+N", FILE_ALT, e -> fileNew() );
813
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), "Shortcut+O", FOLDER_OPEN_ALT, e -> fileOpen() );
814
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), "Shortcut+W", null, e -> fileClose(), activeFileEditorIsNull );
815
    final Action fileCloseAllAction = new Action( get( "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), activeFileEditorIsNull );
816
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), "Shortcut+S", FLOPPY_ALT, e -> fileSave(),
817
      createActiveBooleanProperty( FileEditorTab::modifiedProperty ).not() );
818
    final Action fileSaveAsAction = new Action( Messages.get( "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(), activeFileEditorIsNull );
819
    final Action fileSaveAllAction = new Action( get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, e -> fileSaveAll(),
820
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
821
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null, null, e -> fileExit() );
822
823
    // Edit actions
824
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), "Shortcut+Z", UNDO,
825
      e -> getActiveEditor().undo(),
826
      createActiveBooleanProperty( FileEditorTab::canUndoProperty ).not() );
827
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), "Shortcut+Y", REPEAT,
828
      e -> getActiveEditor().redo(),
829
      createActiveBooleanProperty( FileEditorTab::canRedoProperty ).not() );
830
    final Action editFindAction = new Action( Messages.get( "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
831
      e -> find(),
832
      activeFileEditorIsNull );
833
    final Action editReplaceAction = new Action( Messages.get( "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
834
      e -> getActiveEditor().replace(),
835
      activeFileEditorIsNull );
836
    final Action editFindNextAction = new Action( Messages.get( "Main.menu.edit.find.next" ), "F3", null,
837
      e -> findNext(),
838
      activeFileEditorIsNull );
839
    final Action editFindPreviousAction = new Action( Messages.get( "Main.menu.edit.find.previous" ), "Shift+F3", null,
840
      e -> getActiveEditor().findPrevious(),
841
      activeFileEditorIsNull );
842
843
    // Insert actions
844
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), "Shortcut+B", BOLD,
845
      e -> getActiveEditor().surroundSelection( "**", "**" ),
846
      activeFileEditorIsNull );
847
    final Action insertItalicAction = new Action( get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
848
      e -> getActiveEditor().surroundSelection( "*", "*" ),
849
      activeFileEditorIsNull );
850
    final Action insertSuperscriptAction = new Action( get( "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
851
      e -> getActiveEditor().surroundSelection( "^", "^" ),
852
      activeFileEditorIsNull );
853
    final Action insertSubscriptAction = new Action( get( "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
854
      e -> getActiveEditor().surroundSelection( "~", "~" ),
855
      activeFileEditorIsNull );
856
    final Action insertStrikethroughAction = new Action( get( "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
857
      e -> getActiveEditor().surroundSelection( "~~", "~~" ),
858
      activeFileEditorIsNull );
859
    final Action insertBlockquoteAction = new Action( get( "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
860
      e -> getActiveEditor().surroundSelection( "\n\n> ", "" ),
861
      activeFileEditorIsNull );
862
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), "Shortcut+K", CODE,
863
      e -> getActiveEditor().surroundSelection( "`", "`" ),
864
      activeFileEditorIsNull );
865
    final Action insertFencedCodeBlockAction = new Action( get( "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
866
      e -> getActiveEditor().surroundSelection( "\n\n```\n", "\n```\n\n", get( "Main.menu.insert.fenced_code_block.prompt" ) ),
867
      activeFileEditorIsNull );
868
869
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), "Shortcut+L", LINK,
870
      e -> getActiveEditor().insertLink(),
871
      activeFileEditorIsNull );
872
    final Action insertImageAction = new Action( get( "Main.menu.insert.image" ), "Shortcut+G", PICTURE_ALT,
873
      e -> getActiveEditor().insertImage(),
874
      activeFileEditorIsNull );
875
876
    final Action[] headers = new Action[ 6 ];
877
878
    // Insert header actions (H1 ... H6)
879
    for( int i = 1; i <= 6; i++ ) {
880
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
881
      final String markup = String.format( "%n%n%s ", hashes );
882
      final String text = get( "Main.menu.insert.header_" + i );
883
      final String accelerator = "Shortcut+" + i;
884
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
885
886
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
887
        e -> getActiveEditor().surroundSelection( markup, "", prompt ),
888
        activeFileEditorIsNull );
889
    }
890
891
    final Action insertUnorderedListAction = new Action(
892
      get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
893
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
894
      activeFileEditorIsNull );
895
    final Action insertOrderedListAction = new Action(
896
      get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
897
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
898
      activeFileEditorIsNull );
899
    final Action insertHorizontalRuleAction = new Action(
900
      get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
901
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
902
      activeFileEditorIsNull );
903
904
    // Tools actions
905
    final Action toolsScriptAction = new Action(
906
      get( "Main.menu.tools.script" ), null, null, e -> toolsScript() );
907
908
    // Help actions
909
    final Action helpAboutAction = new Action(
910
      get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
911
912
    //---- MenuBar ----
913
    final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
914
      fileNewAction,
915
      fileOpenAction,
916
      null,
917
      fileCloseAction,
918
      fileCloseAllAction,
919
      null,
920
      fileSaveAction,
921
      fileSaveAsAction,
922
      fileSaveAllAction,
923
      null,
924
      fileExitAction );
925
926
    final Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
927
      editUndoAction,
928
      editRedoAction,
929
      editFindAction,
930
      editReplaceAction,
931
      editFindNextAction,
932
      editFindPreviousAction );
933
934
    final Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
935
      insertBoldAction,
936
      insertItalicAction,
937
      insertSuperscriptAction,
938
      insertSubscriptAction,
939
      insertStrikethroughAction,
940
      insertBlockquoteAction,
941
      insertCodeAction,
942
      insertFencedCodeBlockAction,
943
      null,
944
      insertLinkAction,
945
      insertImageAction,
946
      null,
947
      headers[ 0 ],
948
      headers[ 1 ],
949
      headers[ 2 ],
950
      headers[ 3 ],
951
      headers[ 4 ],
952
      headers[ 5 ],
953
      null,
954
      insertUnorderedListAction,
955
      insertOrderedListAction,
956
      insertHorizontalRuleAction );
957
958
    final Menu toolsMenu = ActionUtils.createMenu( get( "Main.menu.tools" ),
959
      toolsScriptAction );
960
961
    final Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
962
      helpAboutAction );
121
  /**
122
   * Listens on the definition pane for double-click events.
123
   */
124
  private VariableNameInjector variableNameInjector;
125
126
  public MainWindow() {
127
    initLayout();
128
    initFindInput();
129
    initSnitch();
130
    initDefinitionListener();
131
    initTabAddedListener();
132
    initTabChangedListener();
133
    initPreferences();
134
  }
135
136
  /**
137
   * Watch for changes to external files. In particular, this awaits
138
   * modifications to any XSL files associated with XML files being edited. When
139
   * an XSL file is modified (external to the application), the snitch's ears
140
   * perk up and the file is reloaded. This keeps the XSL transformation up to
141
   * date with what's on the file system.
142
   */
143
  private void initSnitch() {
144
    getSnitch().addObserver( this );
145
  }
146
147
  /**
148
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
149
   * presses.
150
   */
151
  private void initFindInput() {
152
    final TextField input = getFindTextField();
153
154
    input.setOnKeyPressed( (KeyEvent event) -> {
155
      switch( event.getCode() ) {
156
        case F3:
157
        case ENTER:
158
          findNext();
159
          break;
160
        case F:
161
          if( !event.isControlDown() ) {
162
            break;
163
          }
164
        case ESCAPE:
165
          getStatusBar().setGraphic( null );
166
          getActiveFileEditor().getEditorPane().requestFocus();
167
          break;
168
      }
169
    } );
170
171
    // Remove when the input field loses focus.
172
    input.focusedProperty().addListener(
173
      (
174
        final ObservableValue<? extends Boolean> focused,
175
        final Boolean oFocus,
176
        final Boolean nFocus) -> {
177
        if( !nFocus ) {
178
          getStatusBar().setGraphic( null );
179
        }
180
      }
181
    );
182
  }
183
184
  /**
185
   * Listen for file editor tab pane to receive an open definition source event.
186
   */
187
  private void initDefinitionListener() {
188
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
189
      (ObservableValue<? extends Path> definitionFile,
190
        final Path oldPath, final Path newPath) -> {
191
        openDefinition( newPath );
192
193
        // Indirectly refresh the resolved map.
194
        setProcessors( null );
195
        updateDefinitionPane();
196
197
        try {
198
          getSnitch().ignore( oldPath );
199
          getSnitch().listen( newPath );
200
        }
201
        catch( final IOException ex ) {
202
          error( ex );
203
        }
204
205
        // Will create new processors and therefore a new resolved map.
206
        refreshSelectedTab( getActiveFileEditor() );
207
      }
208
    );
209
  }
210
211
  /**
212
   * When tabs are added, hook the various change listeners onto the new tab so
213
   * that the preview pane refreshes as necessary.
214
   */
215
  private void initTabAddedListener() {
216
    final FileEditorTabPane editorPane = getFileEditorPane();
217
218
    // Make sure the text processor kicks off when new files are opened.
219
    final ObservableList<Tab> tabs = editorPane.getTabs();
220
221
    // Update the preview pane on tab changes.
222
    tabs.addListener(
223
      (final Change<? extends Tab> change) -> {
224
        while( change.next() ) {
225
          if( change.wasAdded() ) {
226
            // Multiple tabs can be added simultaneously.
227
            for( final Tab newTab : change.getAddedSubList() ) {
228
              final FileEditorTab tab = (FileEditorTab)newTab;
229
230
              initTextChangeListener( tab );
231
              initCaretParagraphListener( tab );
232
//              initSyntaxListener( tab );
233
            }
234
          }
235
        }
236
      }
237
    );
238
  }
239
240
  /**
241
   * Reloads the preferences from the previous session.
242
   */
243
  private void initPreferences() {
244
    restoreDefinitionSource();
245
    getFileEditorPane().restorePreferences();
246
    updateDefinitionPane();
247
  }
248
249
  /**
250
   * Listen for new tab selection events.
251
   */
252
  private void initTabChangedListener() {
253
    final FileEditorTabPane editorPane = getFileEditorPane();
254
255
    // Update the preview pane changing tabs.
256
    editorPane.addTabSelectionListener(
257
      (ObservableValue<? extends Tab> tabPane,
258
        final Tab oldTab, final Tab newTab) -> {
259
        updateVariableNameInjector();
260
261
        // If there was no old tab, then this is a first time load, which
262
        // can be ignored.
263
        if( oldTab != null ) {
264
          if( newTab == null ) {
265
            closeRemainingTab();
266
          }
267
          else {
268
            // Update the preview with the edited text.
269
            refreshSelectedTab( (FileEditorTab)newTab );
270
          }
271
        }
272
      }
273
    );
274
  }
275
276
  private void initTextChangeListener( final FileEditorTab tab ) {
277
    tab.addTextChangeListener(
278
      (ObservableValue<? extends String> editor,
279
        final String oldValue, final String newValue) -> {
280
        refreshSelectedTab( tab );
281
      }
282
    );
283
  }
284
285
  private void initCaretParagraphListener( final FileEditorTab tab ) {
286
    tab.addCaretParagraphListener(
287
      (ObservableValue<? extends Integer> editor,
288
        final Integer oldValue, final Integer newValue) -> {
289
        refreshSelectedTab( tab );
290
      }
291
    );
292
  }
293
294
  private void updateVariableNameInjector() {
295
    getVariableNameInjector().setFileEditorTab( getActiveFileEditor() );
296
  }
297
298
  private void setVariableNameInjector( final VariableNameInjector injector ) {
299
    this.variableNameInjector = injector;
300
  }
301
302
  private synchronized VariableNameInjector getVariableNameInjector() {
303
    if( this.variableNameInjector == null ) {
304
      final VariableNameInjector vin = createVariableNameInjector();
305
      setVariableNameInjector( vin );
306
    }
307
308
    return this.variableNameInjector;
309
  }
310
311
  private VariableNameInjector createVariableNameInjector() {
312
    final FileEditorTab tab = getActiveFileEditor();
313
    final DefinitionPane pane = getDefinitionPane();
314
315
    return new VariableNameInjector( tab, pane );
316
  }
317
318
  /**
319
   * Add a listener for variable name injection the given tab.
320
   *
321
   * @param tab The tab to inject variable names into upon a double-click.
322
   */
323
  private void initVariableNameInjector( final Tab tab ) {
324
    final FileEditorTab editorTab = (FileEditorTab)tab;
325
  }
326
327
  /**
328
   * Called whenever the preview pane becomes out of sync with the file editor
329
   * tab. This can be called when the text changes, the caret paragraph changes,
330
   * or the file tab changes.
331
   *
332
   * @param tab The file editor tab that has been changed in some fashion.
333
   */
334
  private void refreshSelectedTab( final FileEditorTab tab ) {
335
    if( tab.isFileOpen() ) {
336
      getPreviewPane().setPath( tab.getPath() );
337
338
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
339
      final Position p = tab.getCaretOffset();
340
      getLineNumberText().setText(
341
        get( STATUS_BAR_LINE,
342
             p.getMajor() + 1,
343
             p.getMinor() + 1,
344
             tab.getCaretPosition() + 1
345
        )
346
      );
347
348
      Processor<String> processor = getProcessors().get( tab );
349
350
      if( processor == null ) {
351
        processor = createProcessor( tab );
352
        getProcessors().put( tab, processor );
353
      }
354
355
      try {
356
        getNotifier().clear();
357
        processor.processChain( tab.getEditorText() );
358
      }
359
      catch( final Exception ex ) {
360
        error( ex );
361
      }
362
    }
363
  }
364
365
  /**
366
   * Used to find text in the active file editor window.
367
   */
368
  private void find() {
369
    final TextField input = getFindTextField();
370
    getStatusBar().setGraphic( input );
371
    input.requestFocus();
372
  }
373
374
  public void findNext() {
375
    getActiveFileEditor().searchNext( getFindTextField().getText() );
376
  }
377
378
  /**
379
   * Returns the variable map of interpolated definitions.
380
   *
381
   * @return A map to help dereference variables.
382
   */
383
  private Map<String, String> getResolvedMap() {
384
    return getDefinitionSource().getResolvedMap();
385
  }
386
387
  /**
388
   * Returns the root node for the hierarchical definition source.
389
   *
390
   * @return Data to display in the definition pane.
391
   */
392
  private TreeView<String> getTreeView() {
393
    try {
394
      return getDefinitionSource().asTreeView();
395
    }
396
    catch( Exception e ) {
397
      error( e );
398
    }
399
400
    // Slightly redundant as getDefinitionSource() might have returned an
401
    // empty definition source.
402
    return (new EmptyDefinitionSource()).asTreeView();
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 openDefinition( final Path path ) {
411
    try {
412
      final DefinitionSource ds = createDefinitionSource( path.toString() );
413
      setDefinitionSource( ds );
414
      storeDefinitionSource();
415
      updateDefinitionPane();
416
    }
417
    catch( final Exception e ) {
418
      error( e );
419
    }
420
  }
421
422
  private void updateDefinitionPane() {
423
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
424
  }
425
426
  private void restoreDefinitionSource() {
427
    final Preferences preferences = getPreferences();
428
    final String source = preferences.get( PERSIST_DEFINITION_SOURCE, null );
429
430
    // If there's no definition source set, don't try to load it.
431
    if( source != null ) {
432
      setDefinitionSource( createDefinitionSource( source ) );
433
    }
434
  }
435
436
  private void storeDefinitionSource() {
437
    final Preferences preferences = getPreferences();
438
    final DefinitionSource ds = getDefinitionSource();
439
440
    preferences.put( PERSIST_DEFINITION_SOURCE, ds.toString() );
441
  }
442
443
  /**
444
   * Called when the last open tab is closed to clear the preview pane.
445
   */
446
  private void closeRemainingTab() {
447
    getPreviewPane().clear();
448
  }
449
450
  /**
451
   * Called when an exception occurs that warrants the user's attention.
452
   *
453
   * @param e The exception with a message that the user should know about.
454
   */
455
  private void error( final Exception e ) {
456
    getNotifier().notify( e );
457
  }
458
459
  //---- File actions -------------------------------------------------------
460
  /**
461
   * Called when an observable instance has changed. This is called by both the
462
   * snitch service and the notify service. The snitch service can be called for
463
   * different file types, including definition sources.
464
   *
465
   * @param observable The observed instance.
466
   * @param value The noteworthy item.
467
   */
468
  @Override
469
  public void update( final Observable observable, final Object value ) {
470
    if( value != null ) {
471
      if( observable instanceof Snitch && value instanceof Path ) {
472
        final Path path = (Path)value;
473
        final FileTypePredicate predicate
474
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
475
476
        // Reload definitions.
477
        if( predicate.test( path.toFile() ) ) {
478
          updateDefinitionSource( path );
479
        }
480
481
        updateSelectedTab();
482
      }
483
      else if( observable instanceof Notifier && value instanceof String ) {
484
        updateStatusBar( (String)value );
485
      }
486
    }
487
  }
488
489
  /**
490
   * Updates the status bar to show the given message.
491
   *
492
   * @param s The message to show in the status bar.
493
   */
494
  private void updateStatusBar( final String s ) {
495
    Platform.runLater(
496
      () -> {
497
        final int index = s.indexOf( '\n' );
498
        final String message = s.substring( 0, index > 0 ? index : s.length() );
499
500
        getStatusBar().setText( message );
501
      }
502
    );
503
  }
504
505
  /**
506
   * Called when a file has been modified.
507
   *
508
   * @param file Path to the modified file.
509
   */
510
  private void updateSelectedTab() {
511
    Platform.runLater(
512
      () -> {
513
        // Brute-force XSLT file reload by re-instantiating all processors.
514
        resetProcessors();
515
        refreshSelectedTab( getActiveFileEditor() );
516
      }
517
    );
518
  }
519
520
  /**
521
   * Reloads the definition source from the given path.
522
   *
523
   * @param path The path containing new definition information.
524
   */
525
  private void updateDefinitionSource( final Path path ) {
526
    Platform.runLater(
527
      () -> {
528
        openDefinition( path );
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
    }
570
    catch( final Exception ex ) {
571
      getNotifier().notify( ex );
572
    }
573
  }
574
575
  private void fileSaveAll() {
576
    getFileEditorPane().saveAllEditors();
577
  }
578
579
  private void fileExit() {
580
    final Window window = getWindow();
581
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
582
  }
583
584
  //---- Tools actions
585
  private void toolsScript() {
586
    final String script = getStartupScript();
587
588
    final RScriptDialog dialog = new RScriptDialog(
589
      getWindow(), "Dialog.rScript.title", script );
590
    final Optional<String> result = dialog.showAndWait();
591
592
    result.ifPresent( (String s) -> {
593
      putStartupScript( s );
594
    } );
595
  }
596
597
  /**
598
   * Gets the R startup script from the user preferences.
599
   */
600
  private String getStartupScript() {
601
    return getPreferences().get( PERSIST_R_STARTUP, "" );
602
  }
603
604
  /**
605
   * Puts an R startup script into the user preferences.
606
   */
607
  private void putStartupScript( final String s ) {
608
    try {
609
      getPreferences().put( PERSIST_R_STARTUP, s );
610
    }
611
    catch( final Exception ex ) {
612
      getNotifier().notify( ex );
613
    }
614
  }
615
616
  //---- Help actions -------------------------------------------------------
617
  private void helpAbout() {
618
    Alert alert = new Alert( AlertType.INFORMATION );
619
    alert.setTitle( get( "Dialog.about.title" ) );
620
    alert.setHeaderText( get( "Dialog.about.header" ) );
621
    alert.setContentText( get( "Dialog.about.content" ) );
622
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
623
    alert.initOwner( getWindow() );
624
625
    alert.showAndWait();
626
  }
627
628
  //---- Convenience accessors ----------------------------------------------
629
  private float getFloat( final String key, final float defaultValue ) {
630
    return getPreferences().getFloat( key, defaultValue );
631
  }
632
633
  private Preferences getPreferences() {
634
    return getOptions().getState();
635
  }
636
637
  protected Scene getScene() {
638
    if( this.scene == null ) {
639
      this.scene = createScene();
640
    }
641
642
    return this.scene;
643
  }
644
645
  public Window getWindow() {
646
    return getScene().getWindow();
647
  }
648
649
  private MarkdownEditorPane getActiveEditor() {
650
    final EditorPane pane = getActiveFileEditor().getEditorPane();
651
652
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
653
  }
654
655
  private FileEditorTab getActiveFileEditor() {
656
    return getFileEditorPane().getActiveFileEditor();
657
  }
658
659
  //---- Member accessors ---------------------------------------------------
660
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
661
    this.processors = map;
662
  }
663
664
  private Map<FileEditorTab, Processor<String>> getProcessors() {
665
    if( this.processors == null ) {
666
      setProcessors( new HashMap<>() );
667
    }
668
669
    return this.processors;
670
  }
671
672
  private FileEditorTabPane getFileEditorPane() {
673
    if( this.fileEditorPane == null ) {
674
      this.fileEditorPane = createFileEditorPane();
675
    }
676
677
    return this.fileEditorPane;
678
  }
679
680
  private HTMLPreviewPane getPreviewPane() {
681
    if( this.previewPane == null ) {
682
      this.previewPane = createPreviewPane();
683
    }
684
685
    return this.previewPane;
686
  }
687
688
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
689
    this.definitionSource = definitionSource;
690
  }
691
692
  private DefinitionSource getDefinitionSource() {
693
    if( this.definitionSource == null ) {
694
      this.definitionSource = new EmptyDefinitionSource();
695
    }
696
697
    return this.definitionSource;
698
  }
699
700
  private DefinitionPane getDefinitionPane() {
701
    if( this.definitionPane == null ) {
702
      this.definitionPane = createDefinitionPane();
703
    }
704
705
    return this.definitionPane;
706
  }
707
708
  private Options getOptions() {
709
    return this.options;
710
  }
711
712
  private Snitch getSnitch() {
713
    return this.snitch;
714
  }
715
716
  private Notifier getNotifier() {
717
    return this.notifier;
718
  }
719
720
  public void setMenuBar( final MenuBar menuBar ) {
721
    this.menuBar = menuBar;
722
  }
723
724
  public MenuBar getMenuBar() {
725
    return this.menuBar;
726
  }
727
728
  private Text getLineNumberText() {
729
    if( this.lineNumberText == null ) {
730
      this.lineNumberText = createLineNumberText();
731
    }
732
733
    return this.lineNumberText;
734
  }
735
736
  private synchronized StatusBar getStatusBar() {
737
    if( this.statusBar == null ) {
738
      this.statusBar = createStatusBar();
739
    }
740
741
    return this.statusBar;
742
  }
743
744
  private TextField getFindTextField() {
745
    if( this.findTextField == null ) {
746
      this.findTextField = createFindTextField();
747
    }
748
749
    return this.findTextField;
750
  }
751
752
  //---- Member creators ----------------------------------------------------
753
  /**
754
   * Factory to create processors that are suited to different file types.
755
   *
756
   * @param tab The tab that is subjected to processing.
757
   *
758
   * @return A processor suited to the file type specified by the tab's path.
759
   */
760
  private Processor<String> createProcessor( final FileEditorTab tab ) {
761
    return createProcessorFactory().createProcessor( tab );
762
  }
763
764
  private ProcessorFactory createProcessorFactory() {
765
    return new ProcessorFactory( getPreviewPane(), getResolvedMap() );
766
  }
767
768
  private DefinitionSource createDefinitionSource( final String path ) {
769
    DefinitionSource ds;
770
771
    try {
772
      ds = createDefinitionFactory().createDefinitionSource( path );
773
774
      if( ds instanceof FileDefinitionSource ) {
775
        try {
776
          getSnitch().listen( ((FileDefinitionSource)ds).getPath() );
777
        }
778
        catch( final IOException ex ) {
779
          error( ex );
780
        }
781
      }
782
    }
783
    catch( final Exception ex ) {
784
      ds = new EmptyDefinitionSource();
785
      error( ex );
786
    }
787
788
    return ds;
789
  }
790
791
  private TextField createFindTextField() {
792
    return new TextField();
793
  }
794
795
  /**
796
   * Create an editor pane to hold file editor tabs.
797
   *
798
   * @return A new instance, never null.
799
   */
800
  private FileEditorTabPane createFileEditorPane() {
801
    return new FileEditorTabPane();
802
  }
803
804
  private HTMLPreviewPane createPreviewPane() {
805
    return new HTMLPreviewPane();
806
  }
807
808
  private DefinitionPane createDefinitionPane() {
809
    return new DefinitionPane( getTreeView() );
810
  }
811
812
  private DefinitionFactory createDefinitionFactory() {
813
    return new DefinitionFactory();
814
  }
815
816
  private StatusBar createStatusBar() {
817
    return new StatusBar();
818
  }
819
820
  private Scene createScene() {
821
    final SplitPane splitPane = new SplitPane(
822
      getDefinitionPane().getNode(),
823
      getFileEditorPane().getNode(),
824
      getPreviewPane().getNode() );
825
826
    splitPane.setDividerPositions(
827
      getFloat( K_PANE_SPLIT_DEFINITION, .10f ),
828
      getFloat( K_PANE_SPLIT_EDITOR, .45f ),
829
      getFloat( K_PANE_SPLIT_PREVIEW, .45f ) );
830
831
    // See: http://broadlyapplicable.blogspot.ca/2015/03/javafx-capture-restorePreferences-splitpane.html
832
    final BorderPane borderPane = new BorderPane();
833
    borderPane.setPrefSize( 1024, 800 );
834
    borderPane.setTop( createMenuBar() );
835
    borderPane.setBottom( getStatusBar() );
836
    borderPane.setCenter( splitPane );
837
838
    final VBox box = new VBox();
839
    box.setAlignment( Pos.BASELINE_CENTER );
840
    box.getChildren().add( getLineNumberText() );
841
    getStatusBar().getRightItems().add( box );
842
843
    return new Scene( borderPane );
844
  }
845
846
  private Text createLineNumberText() {
847
    return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) );
848
  }
849
850
  private Node createMenuBar() {
851
    final BooleanBinding activeFileEditorIsNull = getFileEditorPane().activeFileEditorProperty().isNull();
852
853
    // File actions
854
    final Action fileNewAction = new Action( get( "Main.menu.file.new" ),
855
                                             "Shortcut+N", FILE_ALT,
856
                                             e -> fileNew() );
857
    final Action fileOpenAction = new Action( get( "Main.menu.file.open" ),
858
                                              "Shortcut+O", FOLDER_OPEN_ALT,
859
                                              e -> fileOpen() );
860
    final Action fileCloseAction = new Action( get( "Main.menu.file.close" ),
861
                                               "Shortcut+W", null,
862
                                               e -> fileClose(),
863
                                               activeFileEditorIsNull );
864
    final Action fileCloseAllAction = new Action( get(
865
      "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(),
866
                                                  activeFileEditorIsNull );
867
    final Action fileSaveAction = new Action( get( "Main.menu.file.save" ),
868
                                              "Shortcut+S", FLOPPY_ALT,
869
                                              e -> fileSave(),
870
                                              createActiveBooleanProperty(
871
                                                FileEditorTab::modifiedProperty ).not() );
872
    final Action fileSaveAsAction = new Action( Messages.get(
873
      "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(),
874
                                                activeFileEditorIsNull );
875
    final Action fileSaveAllAction = new Action(
876
      get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null,
877
      e -> fileSaveAll(),
878
      Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) );
879
    final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), null,
880
                                              null, e -> fileExit() );
881
882
    // Edit actions
883
    final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ),
884
                                              "Shortcut+Z", UNDO,
885
                                              e -> getActiveEditor().undo(),
886
                                              createActiveBooleanProperty(
887
                                                FileEditorTab::canUndoProperty ).not() );
888
    final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ),
889
                                              "Shortcut+Y", REPEAT,
890
                                              e -> getActiveEditor().redo(),
891
                                              createActiveBooleanProperty(
892
                                                FileEditorTab::canRedoProperty ).not() );
893
    final Action editFindAction = new Action( Messages.get(
894
      "Main.menu.edit.find" ), "Ctrl+F", SEARCH,
895
                                              e -> find(),
896
                                              activeFileEditorIsNull );
897
    final Action editReplaceAction = new Action( Messages.get(
898
      "Main.menu.edit.find.replace" ), "Shortcut+H", RETWEET,
899
                                                 e -> getActiveEditor().replace(),
900
                                                 activeFileEditorIsNull );
901
    final Action editFindNextAction = new Action( Messages.get(
902
      "Main.menu.edit.find.next" ), "F3", null,
903
                                                  e -> findNext(),
904
                                                  activeFileEditorIsNull );
905
    final Action editFindPreviousAction = new Action( Messages.get(
906
      "Main.menu.edit.find.previous" ), "Shift+F3", null,
907
                                                      e -> getActiveEditor().findPrevious(),
908
                                                      activeFileEditorIsNull );
909
910
    // Insert actions
911
    final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ),
912
                                                "Shortcut+B", BOLD,
913
                                                e -> getActiveEditor().surroundSelection(
914
                                                  "**", "**" ),
915
                                                activeFileEditorIsNull );
916
    final Action insertItalicAction = new Action(
917
      get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC,
918
      e -> getActiveEditor().surroundSelection( "*", "*" ),
919
      activeFileEditorIsNull );
920
    final Action insertSuperscriptAction = new Action( get(
921
      "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT,
922
                                                       e -> getActiveEditor().surroundSelection(
923
                                                         "^", "^" ),
924
                                                       activeFileEditorIsNull );
925
    final Action insertSubscriptAction = new Action( get(
926
      "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT,
927
                                                     e -> getActiveEditor().surroundSelection(
928
                                                       "~", "~" ),
929
                                                     activeFileEditorIsNull );
930
    final Action insertStrikethroughAction = new Action( get(
931
      "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH,
932
                                                         e -> getActiveEditor().surroundSelection(
933
                                                           "~~", "~~" ),
934
                                                         activeFileEditorIsNull );
935
    final Action insertBlockquoteAction = new Action( get(
936
      "Main.menu.insert.blockquote" ), "Ctrl+Q", QUOTE_LEFT, // not Shortcut+Q because of conflict on Mac
937
                                                      e -> getActiveEditor().surroundSelection(
938
                                                        "\n\n> ", "" ),
939
                                                      activeFileEditorIsNull );
940
    final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ),
941
                                                "Shortcut+K", CODE,
942
                                                e -> getActiveEditor().surroundSelection(
943
                                                  "`", "`" ),
944
                                                activeFileEditorIsNull );
945
    final Action insertFencedCodeBlockAction = new Action( get(
946
      "Main.menu.insert.fenced_code_block" ), "Shortcut+Shift+K", FILE_CODE_ALT,
947
                                                           e -> getActiveEditor().surroundSelection(
948
                                                             "\n\n```\n",
949
                                                             "\n```\n\n", get(
950
                                                               "Main.menu.insert.fenced_code_block.prompt" ) ),
951
                                                           activeFileEditorIsNull );
952
953
    final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ),
954
                                                "Shortcut+L", LINK,
955
                                                e -> getActiveEditor().insertLink(),
956
                                                activeFileEditorIsNull );
957
    final Action insertImageAction = new Action( get( "Main.menu.insert.image" ),
958
                                                 "Shortcut+G", PICTURE_ALT,
959
                                                 e -> getActiveEditor().insertImage(),
960
                                                 activeFileEditorIsNull );
961
962
    final Action[] headers = new Action[ 6 ];
963
964
    // Insert header actions (H1 ... H6)
965
    for( int i = 1; i <= 6; i++ ) {
966
      final String hashes = new String( new char[ i ] ).replace( "\0", "#" );
967
      final String markup = String.format( "%n%n%s ", hashes );
968
      final String text = get( "Main.menu.insert.header_" + i );
969
      final String accelerator = "Shortcut+" + i;
970
      final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" );
971
972
      headers[ i - 1 ] = new Action( text, accelerator, HEADER,
973
                                     e -> getActiveEditor().surroundSelection(
974
                                       markup, "", prompt ),
975
                                     activeFileEditorIsNull );
976
    }
977
978
    final Action insertUnorderedListAction = new Action(
979
      get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL,
980
      e -> getActiveEditor().surroundSelection( "\n\n* ", "" ),
981
      activeFileEditorIsNull );
982
    final Action insertOrderedListAction = new Action(
983
      get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL,
984
      e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ),
985
      activeFileEditorIsNull );
986
    final Action insertHorizontalRuleAction = new Action(
987
      get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null,
988
      e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ),
989
      activeFileEditorIsNull );
990
991
    // Tools actions
992
    final Action toolsScriptAction = new Action(
993
      get( "Main.menu.tools.script" ), null, null, e -> toolsScript() );
994
995
    // Help actions
996
    final Action helpAboutAction = new Action(
997
      get( "Main.menu.help.about" ), null, null, e -> helpAbout() );
998
999
    //---- MenuBar ----
1000
    final Menu fileMenu = ActionUtils.createMenu( get( "Main.menu.file" ),
1001
                                                  fileNewAction,
1002
                                                  fileOpenAction,
1003
                                                  null,
1004
                                                  fileCloseAction,
1005
                                                  fileCloseAllAction,
1006
                                                  null,
1007
                                                  fileSaveAction,
1008
                                                  fileSaveAsAction,
1009
                                                  fileSaveAllAction,
1010
                                                  null,
1011
                                                  fileExitAction );
1012
1013
    final Menu editMenu = ActionUtils.createMenu( get( "Main.menu.edit" ),
1014
                                                  editUndoAction,
1015
                                                  editRedoAction,
1016
                                                  editFindAction,
1017
                                                  editReplaceAction,
1018
                                                  editFindNextAction,
1019
                                                  editFindPreviousAction );
1020
1021
    final Menu insertMenu = ActionUtils.createMenu( get( "Main.menu.insert" ),
1022
                                                    insertBoldAction,
1023
                                                    insertItalicAction,
1024
                                                    insertSuperscriptAction,
1025
                                                    insertSubscriptAction,
1026
                                                    insertStrikethroughAction,
1027
                                                    insertBlockquoteAction,
1028
                                                    insertCodeAction,
1029
                                                    insertFencedCodeBlockAction,
1030
                                                    null,
1031
                                                    insertLinkAction,
1032
                                                    insertImageAction,
1033
                                                    null,
1034
                                                    headers[ 0 ],
1035
                                                    headers[ 1 ],
1036
                                                    headers[ 2 ],
1037
                                                    headers[ 3 ],
1038
                                                    headers[ 4 ],
1039
                                                    headers[ 5 ],
1040
                                                    null,
1041
                                                    insertUnorderedListAction,
1042
                                                    insertOrderedListAction,
1043
                                                    insertHorizontalRuleAction );
1044
1045
    final Menu toolsMenu = ActionUtils.createMenu( get( "Main.menu.tools" ),
1046
                                                   toolsScriptAction );
1047
1048
    final Menu helpMenu = ActionUtils.createMenu( get( "Main.menu.help" ),
1049
                                                  helpAboutAction );
9631050
9641051
    menuBar = new MenuBar( fileMenu, editMenu, insertMenu, toolsMenu, helpMenu );
M src/main/java/com/scrivenvar/definition/DefinitionPane.java
3737
import javafx.collections.ObservableList;
3838
import javafx.event.EventHandler;
39
import javafx.event.EventType;
3940
import javafx.scene.Node;
4041
import javafx.scene.control.MultipleSelectionModel;
4142
import javafx.scene.control.SelectionMode;
4243
import javafx.scene.control.TreeItem;
4344
import javafx.scene.control.TreeView;
4445
import javafx.scene.input.MouseButton;
46
import static javafx.scene.input.MouseButton.PRIMARY;
4547
import javafx.scene.input.MouseEvent;
48
import static javafx.scene.input.MouseEvent.MOUSE_CLICKED;
4649
4750
/**
...
7376
   * Allows observers to receive double-click events on the tree view.
7477
   *
75
   * @param handler The handler that
78
   * @param handler The handler that will receive double-click events.
7679
   */
7780
  public void addBranchSelectedListener(
7881
    final EventHandler<? super MouseEvent> handler ) {
82
    getTreeView().addEventHandler(
83
      MouseEvent.ANY, event -> {
84
        final MouseButton button = event.getButton();
85
        final int clicks = event.getClickCount();
86
        final EventType<? extends MouseEvent> eventType = event.getEventType();
7987
80
    getTreeView().addEventHandler( MouseEvent.ANY, event -> {
81
      if( event.getButton().equals( MouseButton.PRIMARY ) && event.getClickCount() == 2 ) {
82
        if( event.getEventType().equals( MouseEvent.MOUSE_CLICKED ) ) {
83
          handler.handle( event );
88
        if( PRIMARY.equals( button ) && clicks == 2 ) {
89
          if( MOUSE_CLICKED.equals( eventType ) ) {
90
            handler.handle( event );
91
          }
92
93
          event.consume();
8494
        }
95
      } );
96
  }
8597
86
        event.consume();
87
      }
88
    } );
98
  /**
99
   * Allows observers to stop receiving double-click events on the tree view.
100
   *
101
   * @param handler The handler that will no longer receive double-click events.
102
   */
103
  public void removeBranchSelectedListener(
104
    final EventHandler<? super MouseEvent> handler ) {
105
    getTreeView().removeEventHandler( MouseEvent.ANY, handler );
89106
  }
90107
...
182199
    String path = word;
183200
201
    // Current tree item.
184202
    TreeItem<String> cItem = getTreeRoot();
203
204
    // Previous tree item.
185205
    TreeItem<String> pItem = cItem;
186206
M src/main/java/com/scrivenvar/editors/VariableNameInjector.java
3838
import static com.scrivenvar.util.Lists.getFirst;
3939
import static com.scrivenvar.util.Lists.getLast;
40
import static java.lang.Character.isSpaceChar;
41
import static java.lang.Character.isWhitespace;
42
import static java.lang.Math.min;
43
import java.nio.file.Path;
44
import java.util.function.Consumer;
45
import javafx.collections.ObservableList;
46
import javafx.event.Event;
47
import javafx.scene.control.IndexRange;
48
import javafx.scene.control.TreeItem;
49
import javafx.scene.control.TreeView;
50
import javafx.scene.input.InputEvent;
51
import javafx.scene.input.KeyCode;
52
import static javafx.scene.input.KeyCode.AT;
53
import static javafx.scene.input.KeyCode.DIGIT2;
54
import static javafx.scene.input.KeyCode.ENTER;
55
import static javafx.scene.input.KeyCode.MINUS;
56
import static javafx.scene.input.KeyCode.SPACE;
57
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
58
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
59
import javafx.scene.input.KeyEvent;
60
import javafx.scene.input.MouseEvent;
61
import org.fxmisc.richtext.StyledTextArea;
62
import org.fxmisc.wellbehaved.event.EventPattern;
63
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
64
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
65
import org.fxmisc.wellbehaved.event.InputMap;
66
import static org.fxmisc.wellbehaved.event.InputMap.consume;
67
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
68
69
/**
70
 * Provides the logic for injecting variable names within the editor.
71
 *
72
 * @author White Magic Software, Ltd.
73
 */
74
public class VariableNameInjector {
75
76
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
77
78
  private static final int NO_DIFFERENCE = -1;
79
80
  private final Settings settings = Services.load( Settings.class );
81
82
  /**
83
   * Used to capture keyboard events once the user presses @.
84
   */
85
  private InputMap<InputEvent> keyboardMap;
86
87
  private FileEditorTab tab;
88
  private DefinitionPane definitionPane;
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
   * Empty constructor.
97
   */
98
  private VariableNameInjector() {
99
  }
100
101
  public static void listen( final FileEditorTab tab, final DefinitionPane pane ) {
102
    final VariableNameInjector vni = new VariableNameInjector();
103
104
    vni.setFileEditorTab( tab );
105
    vni.setDefinitionPane( pane );
106
    vni.initBranchSelectedListener();
107
    vni.initKeyboardEventListeners();
108
  }
109
110
  /**
111
   * Traps double-click events on the definition pane.
112
   */
113
  private void initBranchSelectedListener() {
114
    getDefinitionPane().addBranchSelectedListener( (final MouseEvent event) -> {
115
      final Object source = event.getSource();
116
117
      if( source instanceof TreeView ) {
118
        final TreeView tree = (TreeView)source;
119
        final TreeItem item = (TreeItem)tree.getSelectionModel().getSelectedItem();
120
121
        if( item instanceof VariableTreeItem ) {
122
          final VariableTreeItem var = (VariableTreeItem)item;
123
          final String text = decorate( var.toPath() );
124
125
          replaceSelection( text );
126
        }
127
      }
128
    } );
129
  }
130
131
  /**
132
   * Traps keys for performing various short-cut tasks, such as @-mode variable
133
   * insertion and control+space for variable autocomplete.
134
   *
135
   * @ key is pressed, a new keyboard map is inserted in place of the current
136
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
137
   *
138
   * @see createKeyboardMap()
139
   */
140
  private void initKeyboardEventListeners() {
141
    // Control and space are pressed.
142
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
143
144
    // @ key in Linux?
145
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
146
    // @ key in Windows.
147
    addEventListener( keyPressed( AT ), this::vMode );
148
  }
149
150
  /**
151
   * The @ symbol is a short-cut to inserting a YAML variable reference.
152
   *
153
   * @param e Superfluous information about the key that was pressed.
154
   */
155
  private void vMode( KeyEvent e ) {
156
    setInitialCaretPosition();
157
    vModeStart();
158
    vModeAutocomplete();
159
  }
160
161
  /**
162
   * Receives key presses until the user completes the variable selection. This
163
   * allows the arrow keys to be used for selecting variables.
164
   *
165
   * @param e The key that was pressed.
166
   */
167
  private void vModeKeyPressed( KeyEvent e ) {
168
    final KeyCode keyCode = e.getCode();
169
170
    switch( keyCode ) {
171
      case BACK_SPACE:
172
        // Don't decorate the variable upon exiting vMode.
173
        vModeBackspace();
174
        break;
175
176
      case ESCAPE:
177
        // Don't decorate the variable upon exiting vMode.
178
        vModeStop();
179
        break;
180
181
      case ENTER:
182
      case PERIOD:
183
      case RIGHT:
184
      case END:
185
        // Stop at a leaf node, ENTER means accept.
186
        if( vModeConditionalComplete() && keyCode == ENTER ) {
187
          vModeStop();
188
189
          // Decorate the variable upon exiting vMode.
190
          decorate();
191
        }
192
        break;
193
194
      case UP:
195
        cyclePathPrev();
196
        break;
197
198
      case DOWN:
199
        cyclePathNext();
200
        break;
201
202
      default:
203
        vModeFilterKeyPressed( e );
204
        break;
205
    }
206
207
    e.consume();
208
  }
209
210
  private void vModeBackspace() {
211
    deleteSelection();
212
213
    // Break out of variable mode by back spacing to the original position.
214
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
215
      vModeAutocomplete();
216
    }
217
    else {
218
      vModeStop();
219
    }
220
  }
221
222
  /**
223
   * Updates the text with the path selected (or typed) by the user.
224
   */
225
  private void vModeAutocomplete() {
226
    final TreeItem<String> node = getCurrentNode();
227
228
    if( node != null && !node.isLeaf() ) {
229
      final String word = getLastPathWord();
230
      final String label = node.getValue();
231
      final int delta = difference( label, word );
232
      final String remainder = delta == NO_DIFFERENCE
233
        ? label
234
        : label.substring( delta );
235
236
      final StyledTextArea textArea = getEditor();
237
      final int posBegan = getCurrentCaretPosition();
238
      final int posEnded = posBegan + remainder.length();
239
240
      textArea.replaceSelection( remainder );
241
242
      if( posEnded - posBegan > 0 ) {
243
        textArea.selectRange( posEnded, posBegan );
244
      }
245
246
      expand( node );
247
    }
248
  }
249
250
  /**
251
   * Only variable name keys can pass through the filter. This is called when
252
   * the user presses a key.
253
   *
254
   * @param e The key that was pressed.
255
   */
256
  private void vModeFilterKeyPressed( final KeyEvent e ) {
257
    if( isVariableNameKey( e ) ) {
258
      typed( e.getText() );
259
    }
260
  }
261
262
  /**
263
   * Performs an autocomplete depending on whether the user has finished typing
264
   * in a word. If there is a selected range, then this will complete the most
265
   * recent word and jump to the next child.
266
   *
267
   * @return true The auto-completed node was a terminal node.
268
   */
269
  private boolean vModeConditionalComplete() {
270
    acceptPath();
271
272
    final TreeItem<String> node = getCurrentNode();
273
    final boolean terminal = isTerminal( node );
274
275
    if( !terminal ) {
276
      typed( SEPARATOR );
277
    }
278
279
    return terminal;
280
  }
281
282
  /**
283
   * Pressing control+space will find a node that matches the current word and
284
   * substitute the YAML variable reference. This is called when the user is not
285
   * editing in vMode.
286
   *
287
   * @param e Ignored -- it can only be Ctrl+Space.
288
   */
289
  private void autocomplete( final KeyEvent e ) {
290
    final String paragraph = getCaretParagraph();
291
    final int[] boundaries = getWordBoundaries( paragraph );
292
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
293
294
    VariableTreeItem<String> leaf = findLeaf( word );
295
296
    if( leaf == null ) {
297
      // If a leaf doesn't match using "starts with", then try using "contains".
298
      leaf = findLeaf( word, true );
299
    }
300
301
    if( leaf != null ) {
302
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
303
      decorate();
304
      expand( leaf );
305
    }
306
  }
307
308
  /**
309
   * Called when autocomplete finishes on a valid leaf or when the user presses
310
   * Enter to finish manual autocomplete.
311
   */
312
  private void decorate() {
313
    // A little bit of duplication...
314
    final String paragraph = getCaretParagraph();
315
    final int[] boundaries = getWordBoundaries( paragraph );
316
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
317
318
    final String newVariable = decorate( old );
319
320
    final int posEnded = getCurrentCaretPosition();
321
    final int posBegan = posEnded - old.length();
322
323
    getEditor().replaceText( posBegan, posEnded, newVariable );
324
  }
325
326
  /**
327
   * Called when user double-clicks on a tree view item.
328
   *
329
   * @param variable The variable to decorate.
330
   */
331
  private String decorate( final String variable ) {
332
    return getVariableDecorator().decorate( variable );
333
  }
334
335
  /**
336
   * Inserts the given string at the current caret position, or replaces
337
   * selected text (if any).
338
   *
339
   * @param s The string to inject.
340
   */
341
  private void replaceSelection( final String s ) {
342
    getEditor().replaceSelection( s );
343
  }
344
345
  /**
346
   * Updates the text at the given position within the current paragraph.
347
   *
348
   * @param posBegan The starting index in the paragraph text to replace.
349
   * @param posEnded The ending index in the paragraph text to replace.
350
   * @param text Overwrite the paragraph substring with this text.
351
   */
352
  private void replaceText(
353
    final int posBegan, final int posEnded, final String text ) {
354
    final int p = getCurrentParagraph();
355
356
    getEditor().replaceText( p, posBegan, p, posEnded, text );
357
  }
358
359
  /**
360
   * Returns the caret's current paragraph position.
361
   *
362
   * @return A number greater than or equal to 0.
363
   */
364
  private int getCurrentParagraph() {
365
    return getEditor().getCurrentParagraph();
366
  }
367
368
  /**
369
   * Returns current word boundary indexes into the current paragraph, including
370
   * punctuation.
371
   *
372
   * @param p The paragraph wherein to hunt word boundaries.
373
   * @param offset The offset into the paragraph to begin scanning left and
374
   * right.
375
   *
376
   * @return The starting and ending index of the word closest to the caret.
377
   */
378
  private int[] getWordBoundaries( final String p, final int offset ) {
379
    // Remove dashes, but retain hyphens. Retain same number of characters
380
    // to preserve relative indexes.
381
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
382
383
    return getWordAt( paragraph, offset );
384
  }
385
386
  /**
387
   * Helper method to get the word boundaries for the current paragraph.
388
   *
389
   * @param paragraph
390
   *
391
   * @return
392
   */
393
  private int[] getWordBoundaries( final String paragraph ) {
394
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
395
  }
396
397
  /**
398
   * Given an arbitrary offset into a string, this returns the word at that
399
   * index. The inputs and outputs include:
400
   *
401
   * <ul>
402
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
403
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
404
   * <li>start of a word: <code>hello |world!</code> ("world!");</li>
405
   * <li>within a word: <code>hello wo|rld!</code> ("world!");</li>
406
   * <li>end of a paragraph: <code>hello world!|</code> ("world!");</li>
407
   * <li>start of a paragraph: <code>|hello world!</code> ("hello!"); or</li>
408
   * <li>after punctuation: <code>hello world!|</code> ("world!").</li>
409
   * </ul>
410
   *
411
   * @param p The string to scan for a word.
412
   * @param offset The offset within s to begin searching for the nearest word
413
   * boundary, must not be out of bounds of s.
414
   *
415
   * @return The word in s at the offset.
416
   *
417
   * @see getWordBegan( String, int )
418
   * @see getWordEnded( String, int )
419
   */
420
  private int[] getWordAt( final String p, final int offset ) {
421
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
422
  }
423
424
  /**
425
   * Returns the index into s where a word begins.
426
   *
427
   * @param s Never null.
428
   * @param offset Index into s to begin searching backwards for a word
429
   * boundary.
430
   *
431
   * @return The index where a word begins.
432
   */
433
  private int getWordBegan( final String s, int offset ) {
434
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
435
      offset--;
436
    }
437
438
    return offset;
439
  }
440
441
  /**
442
   * Returns the index into s where a word ends.
443
   *
444
   * @param s Never null.
445
   * @param offset Index into s to begin searching forwards for a word boundary.
446
   *
447
   * @return The index where a word ends.
448
   */
449
  private int getWordEnded( final String s, int offset ) {
450
    final int length = s.length();
451
452
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
453
      offset++;
454
    }
455
456
    return offset;
457
  }
458
459
  /**
460
   * Returns true if the given character can be reasonably expected to be part
461
   * of a word, including punctuation marks.
462
   *
463
   * @param c The character to compare.
464
   *
465
   * @return false The character is a space character.
466
   */
467
  private boolean isBoundary( final char c ) {
468
    return !isSpaceChar( c );
469
  }
470
471
  /**
472
   * Returns the text for the paragraph that contains the caret.
473
   *
474
   * @return A non-null string, possibly empty.
475
   */
476
  private String getCaretParagraph() {
477
    return getEditor().getText( getCurrentParagraph() );
478
  }
479
480
  /**
481
   * Returns true if the node has children that can be selected (i.e., any
482
   * non-leaves).
483
   *
484
   * @param <T> The type that the TreeItem contains.
485
   * @param node The node to test for terminality.
486
   *
487
   * @return true The node has one branch and its a leaf.
488
   */
489
  private <T> boolean isTerminal( final TreeItem<T> node ) {
490
    final ObservableList<TreeItem<T>> branches = node.getChildren();
491
492
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
493
  }
494
495
  /**
496
   * Inserts text that the user typed at the current caret position, then
497
   * performs an autocomplete for the variable name.
498
   *
499
   * @param text The text to insert, never null.
500
   */
501
  private void typed( final String text ) {
502
    getEditor().replaceSelection( text );
503
    vModeAutocomplete();
504
  }
505
506
  /**
507
   * Called when the user presses either End or Enter key.
508
   */
509
  private void acceptPath() {
510
    final IndexRange range = getSelectionRange();
511
512
    if( range != null ) {
513
      final int rangeEnd = range.getEnd();
514
      final StyledTextArea textArea = getEditor();
515
      textArea.deselect();
516
      textArea.moveTo( rangeEnd );
517
    }
518
  }
519
520
  /**
521
   * Replaces the entirety of the existing path (from the initial caret
522
   * position) with the given path.
523
   *
524
   * @param oldPath The path to replace.
525
   * @param newPath The replacement path.
526
   */
527
  private void replacePath( final String oldPath, final String newPath ) {
528
    final StyledTextArea textArea = getEditor();
529
    final int posBegan = getInitialCaretPosition();
530
    final int posEnded = posBegan + oldPath.length();
531
532
    textArea.deselect();
533
    textArea.replaceText( posBegan, posEnded, newPath );
534
  }
535
536
  /**
537
   * Called when the user presses the Backspace key.
538
   */
539
  private void deleteSelection() {
540
    final StyledTextArea textArea = getEditor();
541
    textArea.replaceSelection( "" );
542
    textArea.deletePreviousChar();
543
  }
544
545
  /**
546
   * Cycles the selected text through the nodes.
547
   *
548
   * @param direction true - next; false - previous
549
   */
550
  private void cycleSelection( final boolean direction ) {
551
    final TreeItem<String> node = getCurrentNode();
552
553
    // Find the sibling for the current selection and replace the current
554
    // selection with the sibling's value
555
    TreeItem< String> cycled = direction
556
      ? node.nextSibling()
557
      : node.previousSibling();
558
559
    // When cycling at the end (or beginning) of the list, jump to the first
560
    // (or last) sibling depending on the cycle direction.
561
    if( cycled == null ) {
562
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
563
    }
564
565
    final String path = getCurrentPath();
566
    final String cycledWord = cycled.getValue();
567
    final String word = getLastPathWord();
568
    final int index = path.indexOf( word );
569
    final String cycledPath = path.substring( 0, index ) + cycledWord;
570
571
    expand( cycled );
572
    replacePath( path, cycledPath );
573
  }
574
575
  /**
576
   * Cycles to the next sibling of the currently selected tree node.
577
   */
578
  private void cyclePathNext() {
579
    cycleSelection( true );
580
  }
581
582
  /**
583
   * Cycles to the previous sibling of the currently selected tree node.
584
   */
585
  private void cyclePathPrev() {
586
    cycleSelection( false );
587
  }
588
589
  /**
590
   * Returns the variable name (or as much as has been typed so far). Returns
591
   * all the characters from the initial caret column to the the first
592
   * whitespace character. This will return a path that contains zero or more
593
   * separators.
594
   *
595
   * @return A non-null string, possibly empty.
596
   */
597
  private String getCurrentPath() {
598
    final String s = extractTextChunk();
599
    final int length = s.length();
600
601
    int i = 0;
602
603
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
604
      i++;
605
    }
606
607
    return s.substring( 0, i );
608
  }
609
610
  private <T> ObservableList<TreeItem<T>> getSiblings(
611
    final TreeItem<T> item ) {
612
    final TreeItem<T> parent = item.getParent();
613
    return parent == null ? item.getChildren() : parent.getChildren();
614
  }
615
616
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
617
    return getFirst( getSiblings( item ), item );
618
  }
619
620
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
621
    return getLast( getSiblings( item ), item );
622
  }
623
624
  /**
625
   * Returns the caret position as an offset into the text.
626
   *
627
   * @return A value from 0 to the length of the text (minus one).
628
   */
629
  private int getCurrentCaretPosition() {
630
    return getEditor().getCaretPosition();
631
  }
632
633
  /**
634
   * Returns the caret position within the current paragraph.
635
   *
636
   * @return A value from 0 to the length of the current paragraph.
637
   */
638
  private int getCurrentCaretColumn() {
639
    return getEditor().getCaretColumn();
640
  }
641
642
  /**
643
   * Returns the last word from the path.
644
   *
645
   * @return The last token.
646
   */
647
  private String getLastPathWord() {
648
    String path = getCurrentPath();
649
650
    int i = path.indexOf( SEPARATOR_CHAR );
651
652
    while( i > 0 ) {
653
      path = path.substring( i + 1 );
654
      i = path.indexOf( SEPARATOR_CHAR );
655
    }
656
657
    return path;
658
  }
659
660
  /**
661
   * Returns text from the initial caret position until some arbitrarily long
662
   * number of characters. The number of characters extracted will be
663
   * getMaxVarLength, or fewer, depending on how many characters remain to be
664
   * extracted. The result from this method is trimmed to the first whitespace
665
   * character.
666
   *
667
   * @return A chunk of text that includes all the words representing a path,
668
   * and then some.
669
   */
670
  private String extractTextChunk() {
671
    final StyledTextArea textArea = getEditor();
672
    final int textBegan = getInitialCaretPosition();
673
    final int remaining = textArea.getLength() - textBegan;
674
    final int textEnded = min( remaining, getMaxVarLength() );
675
676
    try {
677
      return textArea.getText( textBegan, textEnded );
678
    } catch( final Exception e ) {
679
      return textArea.getText();
680
    }
681
  }
682
683
  /**
684
   * Returns the node for the current path.
685
   */
686
  private TreeItem<String> getCurrentNode() {
687
    return findNode( getCurrentPath() );
688
  }
689
690
  /**
691
   * Finds the node that most closely matches the given path.
692
   *
693
   * @param path The path that represents a node.
694
   *
695
   * @return The node for the path, or the root node if the path could not be
696
   * found, but never null.
697
   */
698
  private TreeItem<String> findNode( final String path ) {
699
    return getDefinitionPane().findNode( path );
700
  }
701
702
  /**
703
   * Finds the first leaf having a value that starts with the given text.
704
   *
705
   * @param text The text to find in the definition tree.
706
   *
707
   * @return The leaf that starts with the given text, or null if not found.
708
   */
709
  private VariableTreeItem<String> findLeaf( final String text ) {
710
    return getDefinitionPane().findLeaf( text, false );
711
  }
712
713
  /**
714
   * Finds the first leaf having a value that starts with the given text, or
715
   * contains the text if contains is true.
716
   *
717
   * @param text The text to find in the definition tree.
718
   * @param contains Set true to perform a substring match after a starts with
719
   * match.
720
   *
721
   * @return The leaf that starts with the given text, or null if not found.
722
   */
723
  private VariableTreeItem<String> findLeaf(
724
    final String text,
725
    final boolean contains ) {
726
    return getDefinitionPane().findLeaf( text, contains );
727
  }
728
729
  /**
730
   * Used to ignore typed keys in favour of trapping pressed keys.
731
   *
732
   * @param e The key that was typed.
733
   */
734
  private void vModeKeyTyped( KeyEvent e ) {
735
    e.consume();
736
  }
737
738
  /**
739
   * Used to lazily initialize the keyboard map.
740
   *
741
   * @return Mappings for keyTyped and keyPressed.
742
   */
743
  protected InputMap<InputEvent> createKeyboardMap() {
744
    return sequence(
745
      consume( keyTyped(), this::vModeKeyTyped ),
746
      consume( keyPressed(), this::vModeKeyPressed )
747
    );
748
  }
749
750
  private InputMap<InputEvent> getKeyboardMap() {
751
    if( this.keyboardMap == null ) {
752
      this.keyboardMap = createKeyboardMap();
753
    }
754
755
    return this.keyboardMap;
756
  }
757
758
  /**
759
   * Collapses the tree then expands and selects the given node.
760
   *
761
   * @param node The node to expand.
762
   */
763
  private void expand( final TreeItem<String> node ) {
764
    final DefinitionPane pane = getDefinitionPane();
765
    pane.collapse();
766
    pane.expand( node );
767
    pane.select( node );
768
  }
769
770
  /**
771
   * Returns true iff the key code the user typed can be used as part of a YAML
772
   * variable name.
773
   *
774
   * @param keyEvent Keyboard key press event information.
775
   *
776
   * @return true The key is a value that can be inserted into the text.
777
   */
778
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
779
    final KeyCode kc = keyEvent.getCode();
780
781
    return (kc.isLetterKey()
782
      || kc.isDigitKey()
783
      || (keyEvent.isShiftDown() && kc == MINUS))
784
      && !keyEvent.isControlDown();
785
  }
786
787
  /**
788
   * Starts to capture user input events.
789
   */
790
  private void vModeStart() {
791
    addEventListener( getKeyboardMap() );
792
  }
793
794
  /**
795
   * Restores capturing of user input events to the previous event listener.
796
   * Also asks the processing chain to modify the variable text into a
797
   * machine-readable variable based on the format required by the file type.
798
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
799
   * file (.Rmd, .Rxml) will use `r#xVAR`.
800
   */
801
  private void vModeStop() {
802
    removeEventListener( getKeyboardMap() );
803
  }
804
805
  /**
806
   * Returns a variable decorator that corresponds to the given file type.
807
   *
808
   * @return
809
   */
810
  private VariableDecorator getVariableDecorator() {
811
    return VariableNameDecoratorFactory.newInstance( getFilename() );
812
  }
813
814
  private Path getFilename() {
815
    return getFileEditorTab().getPath();
816
  }
817
818
  /**
819
   * Returns the index where the two strings diverge.
820
   *
821
   * @param s1 The string that could be a substring of s2, null allowed.
822
   * @param s2 The string that could be a substring of s1, null allowed.
823
   *
824
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
825
   * where they differ.
826
   */
827
  @SuppressWarnings( "StringEquality" )
828
  private int difference( final CharSequence s1, final CharSequence s2 ) {
829
    if( s1 == s2 ) {
830
      return NO_DIFFERENCE;
831
    }
832
833
    if( s1 == null || s2 == null ) {
834
      return 0;
835
    }
836
837
    int i = 0;
838
    final int limit = min( s1.length(), s2.length() );
839
840
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
841
      i++;
842
    }
843
844
    // If one string was shorter than the other, that's where they differ.
845
    return i;
846
  }
847
848
  private EditorPane getEditorPane() {
849
    return getFileEditorTab().getEditorPane();
850
  }
851
852
  /**
853
   * Delegates to the file editor pane, and, ultimately, to its text area.
854
   */
855
  private <T extends Event, U extends T> void addEventListener(
856
    final EventPattern<? super T, ? extends U> event,
857
    final Consumer<? super U> consumer ) {
858
    getEditorPane().addEventListener( event, consumer );
859
  }
860
861
  /**
862
   * Delegates to the file editor pane, and, ultimately, to its text area.
863
   *
864
   * @param map The map of methods to events.
865
   */
866
  private void addEventListener( final InputMap<InputEvent> map ) {
867
    getEditorPane().addEventListener( map );
868
  }
869
870
  private void removeEventListener( final InputMap<InputEvent> map ) {
871
    getEditorPane().removeEventListener( map );
872
  }
873
874
  /**
875
   * Returns the position of the caret when variable mode editing was requested.
876
   *
877
   * @return The variable mode caret position.
878
   */
879
  private int getInitialCaretPosition() {
880
    return this.initialCaretPosition;
881
  }
882
883
  /**
884
   * Sets the position of the caret when variable mode editing was requested.
885
   * Stores the current position because only the text that comes afterwards is
886
   * a suitable variable reference.
887
   *
888
   * @return The variable mode caret position.
889
   */
890
  private void setInitialCaretPosition() {
891
    this.initialCaretPosition = getEditor().getCaretPosition();
892
  }
893
894
  private StyledTextArea getEditor() {
895
    return getFileEditorTab().getEditorPane().getEditor();
896
  }
897
898
  public FileEditorTab getFileEditorTab() {
899
    return this.tab;
900
  }
901
902
  private 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;
40
import static java.lang.Character.isWhitespace;
41
import static java.lang.Math.min;
42
import java.nio.file.Path;
43
import java.util.function.Consumer;
44
import javafx.collections.ObservableList;
45
import javafx.event.Event;
46
import javafx.event.EventHandler;
47
import javafx.scene.control.IndexRange;
48
import javafx.scene.control.TreeItem;
49
import javafx.scene.control.TreeView;
50
import javafx.scene.input.InputEvent;
51
import javafx.scene.input.KeyCode;
52
import static javafx.scene.input.KeyCode.AT;
53
import static javafx.scene.input.KeyCode.DIGIT2;
54
import static javafx.scene.input.KeyCode.ENTER;
55
import static javafx.scene.input.KeyCode.MINUS;
56
import static javafx.scene.input.KeyCode.SPACE;
57
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
58
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
59
import javafx.scene.input.KeyEvent;
60
import javafx.scene.input.MouseEvent;
61
import org.fxmisc.richtext.StyledTextArea;
62
import org.fxmisc.wellbehaved.event.EventPattern;
63
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
64
import static org.fxmisc.wellbehaved.event.EventPattern.keyTyped;
65
import org.fxmisc.wellbehaved.event.InputMap;
66
import static org.fxmisc.wellbehaved.event.InputMap.consume;
67
import static org.fxmisc.wellbehaved.event.InputMap.sequence;
68
69
/**
70
 * Provides the logic for injecting variable names within the editor.
71
 *
72
 * @author White Magic Software, Ltd.
73
 */
74
public final class VariableNameInjector {
75
76
  public static final int DEFAULT_MAX_VAR_LENGTH = 64;
77
78
  private static final int NO_DIFFERENCE = -1;
79
80
  /**
81
   * TODO: Move this into settings.
82
   */
83
  private static final String PUNCTUATION = "\"#$%&'()*+,-/:;<=>?@[]^_`{|}~";
84
85
  private final Settings settings = Services.load( Settings.class );
86
87
  /**
88
   * Used to capture keyboard events once the user presses @.
89
   */
90
  private InputMap<InputEvent> keyboardMap;
91
92
  /**
93
   * Position of the variable in the text when in variable mode (0 by default).
94
   */
95
  private int initialCaretPosition;
96
97
  /**
98
   * Recipient of name injections.
99
   */
100
  private FileEditorTab tab;
101
102
  /**
103
   * Initiates double-click events.
104
   */
105
  private DefinitionPane definitionPane;
106
107
  private EventHandler<MouseEvent> panelEventHandler;
108
109
  /**
110
   * Initializes the variable name injector against the given pane.
111
   *
112
   * @param tab The tab to inject variable names into.
113
   * @param pane The definition panel to listen to for double-click events.
114
   */
115
  public VariableNameInjector(
116
    final FileEditorTab tab, final DefinitionPane pane ) {
117
    setFileEditorTab( tab );
118
    setDefinitionPane( pane );
119
    initBranchSelectedListener();
120
    initKeyboardEventListeners();
121
  }
122
123
  /**
124
   * Traps double-click events on the definition pane.
125
   */
126
  private void initBranchSelectedListener() {
127
    final EventHandler<MouseEvent> eventHandler = getPanelEventHandler();
128
    getDefinitionPane().addBranchSelectedListener( eventHandler );
129
  }
130
131
  /**
132
   * Traps keys for performing various short-cut tasks, such as @-mode variable
133
   * insertion and control+space for variable autocomplete.
134
   *
135
   * @ key is pressed, a new keyboard map is inserted in place of the current
136
   * map -- this class goes into "variable edit mode" (a.k.a. vMode).
137
   *
138
   * @see createKeyboardMap()
139
   */
140
  private void initKeyboardEventListeners() {
141
    // Control and space are pressed.
142
    addEventListener( keyPressed( SPACE, CONTROL_DOWN ), this::autocomplete );
143
144
    // @ key in Linux?
145
    addEventListener( keyPressed( DIGIT2, SHIFT_DOWN ), this::vMode );
146
    // @ key in Windows.
147
    addEventListener( keyPressed( AT ), this::vMode );
148
  }
149
150
  /**
151
   * The @ symbol is a short-cut to inserting a YAML variable reference.
152
   *
153
   * @param e Superfluous information about the key that was pressed.
154
   */
155
  private void vMode( KeyEvent e ) {
156
    setInitialCaretPosition();
157
    vModeStart();
158
    vModeAutocomplete();
159
  }
160
161
  /**
162
   * Receives key presses until the user completes the variable selection. This
163
   * allows the arrow keys to be used for selecting variables.
164
   *
165
   * @param e The key that was pressed.
166
   */
167
  private void vModeKeyPressed( KeyEvent e ) {
168
    final KeyCode keyCode = e.getCode();
169
170
    switch( keyCode ) {
171
      case BACK_SPACE:
172
        // Don't decorate the variable upon exiting vMode.
173
        vModeBackspace();
174
        break;
175
176
      case ESCAPE:
177
        // Don't decorate the variable upon exiting vMode.
178
        vModeStop();
179
        break;
180
181
      case ENTER:
182
      case PERIOD:
183
      case RIGHT:
184
      case END:
185
        // Stop at a leaf node, ENTER means accept.
186
        if( vModeConditionalComplete() && keyCode == ENTER ) {
187
          vModeStop();
188
189
          // Decorate the variable upon exiting vMode.
190
          decorate();
191
        }
192
        break;
193
194
      case UP:
195
        cyclePathPrev();
196
        break;
197
198
      case DOWN:
199
        cyclePathNext();
200
        break;
201
202
      default:
203
        vModeFilterKeyPressed( e );
204
        break;
205
    }
206
207
    e.consume();
208
  }
209
210
  private void vModeBackspace() {
211
    deleteSelection();
212
213
    // Break out of variable mode by back spacing to the original position.
214
    if( getCurrentCaretPosition() > getInitialCaretPosition() ) {
215
      vModeAutocomplete();
216
    }
217
    else {
218
      vModeStop();
219
    }
220
  }
221
222
  /**
223
   * Updates the text with the path selected (or typed) by the user.
224
   */
225
  private void vModeAutocomplete() {
226
    final TreeItem<String> node = getCurrentNode();
227
228
    if( node != null && !node.isLeaf() ) {
229
      final String word = getLastPathWord();
230
      final String label = node.getValue();
231
      final int delta = difference( label, word );
232
      final String remainder = delta == NO_DIFFERENCE
233
        ? label
234
        : label.substring( delta );
235
236
      final StyledTextArea textArea = getEditor();
237
      final int posBegan = getCurrentCaretPosition();
238
      final int posEnded = posBegan + remainder.length();
239
240
      textArea.replaceSelection( remainder );
241
242
      if( posEnded - posBegan > 0 ) {
243
        textArea.selectRange( posEnded, posBegan );
244
      }
245
246
      expand( node );
247
    }
248
  }
249
250
  /**
251
   * Only variable name keys can pass through the filter. This is called when
252
   * the user presses a key.
253
   *
254
   * @param e The key that was pressed.
255
   */
256
  private void vModeFilterKeyPressed( final KeyEvent e ) {
257
    if( isVariableNameKey( e ) ) {
258
      typed( e.getText() );
259
    }
260
  }
261
262
  /**
263
   * Performs an autocomplete depending on whether the user has finished typing
264
   * in a word. If there is a selected range, then this will complete the most
265
   * recent word and jump to the next child.
266
   *
267
   * @return true The auto-completed node was a terminal node.
268
   */
269
  private boolean vModeConditionalComplete() {
270
    acceptPath();
271
272
    final TreeItem<String> node = getCurrentNode();
273
    final boolean terminal = isTerminal( node );
274
275
    if( !terminal ) {
276
      typed( SEPARATOR );
277
    }
278
279
    return terminal;
280
  }
281
282
  /**
283
   * Pressing control+space will find a node that matches the current word and
284
   * substitute the YAML variable reference. This is called when the user is not
285
   * editing in vMode.
286
   *
287
   * @param e Ignored -- it can only be Ctrl+Space.
288
   */
289
  private void autocomplete( final KeyEvent e ) {
290
    final String paragraph = getCaretParagraph();
291
    final int[] boundaries = getWordBoundaries( paragraph );
292
    final String word = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
293
294
    VariableTreeItem<String> leaf = findLeaf( word );
295
296
    if( leaf == null ) {
297
      // If a leaf doesn't match using "starts with", then try using "contains".
298
      leaf = findLeaf( word, true );
299
    }
300
301
    if( leaf != null ) {
302
      replaceText( boundaries[ 0 ], boundaries[ 1 ], leaf.toPath() );
303
      decorate();
304
      expand( leaf );
305
    }
306
  }
307
308
  /**
309
   * Called when autocomplete finishes on a valid leaf or when the user presses
310
   * Enter to finish manual autocomplete.
311
   */
312
  private void decorate() {
313
    // A little bit of duplication...
314
    final String paragraph = getCaretParagraph();
315
    final int[] boundaries = getWordBoundaries( paragraph );
316
    final String old = paragraph.substring( boundaries[ 0 ], boundaries[ 1 ] );
317
318
    final String newVariable = decorate( old );
319
320
    final int posEnded = getCurrentCaretPosition();
321
    final int posBegan = posEnded - old.length();
322
323
    getEditor().replaceText( posBegan, posEnded, newVariable );
324
  }
325
326
  /**
327
   * Called when user double-clicks on a tree view item.
328
   *
329
   * @param variable The variable to decorate.
330
   */
331
  private String decorate( final String variable ) {
332
    return getVariableDecorator().decorate( variable );
333
  }
334
335
  /**
336
   * Inserts the given string at the current caret position, or replaces
337
   * selected text (if any).
338
   *
339
   * @param s The string to inject.
340
   */
341
  private void replaceSelection( final String s ) {
342
    getEditor().replaceSelection( s );
343
  }
344
345
  /**
346
   * Updates the text at the given position within the current paragraph.
347
   *
348
   * @param posBegan The starting index in the paragraph text to replace.
349
   * @param posEnded The ending index in the paragraph text to replace.
350
   * @param text Overwrite the paragraph substring with this text.
351
   */
352
  private void replaceText(
353
    final int posBegan, final int posEnded, final String text ) {
354
    final int p = getCurrentParagraph();
355
356
    getEditor().replaceText( p, posBegan, p, posEnded, text );
357
  }
358
359
  /**
360
   * Returns the caret's current paragraph position.
361
   *
362
   * @return A number greater than or equal to 0.
363
   */
364
  private int getCurrentParagraph() {
365
    return getEditor().getCurrentParagraph();
366
  }
367
368
  /**
369
   * Returns current word boundary indexes into the current paragraph, excluding
370
   * punctuation.
371
   *
372
   * @param p The paragraph wherein to hunt word boundaries.
373
   * @param offset The offset into the paragraph to begin scanning left and
374
   * right.
375
   *
376
   * @return The starting and ending index of the word closest to the caret.
377
   */
378
  private int[] getWordBoundaries( final String p, final int offset ) {
379
    // Remove dashes, but retain hyphens. Retain same number of characters
380
    // to preserve relative indexes.
381
    final String paragraph = p.replace( "---", "   " ).replace( "--", "  " );
382
383
    return getWordAt( paragraph, offset );
384
  }
385
386
  /**
387
   * Helper method to get the word boundaries for the current paragraph.
388
   *
389
   * @param paragraph
390
   *
391
   * @return
392
   */
393
  private int[] getWordBoundaries( final String paragraph ) {
394
    return getWordBoundaries( paragraph, getCurrentCaretColumn() );
395
  }
396
397
  /**
398
   * Given an arbitrary offset into a string, this returns the word at that
399
   * index. The inputs and outputs include:
400
   *
401
   * <ul>
402
   * <li>surrounded by space: <code>hello | world!</code> ("");</li>
403
   * <li>end of word: <code>hello| world!</code> ("hello");</li>
404
   * <li>start of a word: <code>hello |world!</code> ("world");</li>
405
   * <li>within a word: <code>hello wo|rld!</code> ("world");</li>
406
   * <li>end of a paragraph: <code>hello world!|</code> ("world");</li>
407
   * <li>start of a paragraph: <code>|hello world!</code> ("hello"); or</li>
408
   * <li>after punctuation: <code>hello world!|</code> ("world").</li>
409
   * </ul>
410
   *
411
   * @param p The string to scan for a word.
412
   * @param offset The offset within s to begin searching for the nearest word
413
   * boundary, must not be out of bounds of s.
414
   *
415
   * @return The word in s at the offset.
416
   *
417
   * @see getWordBegan( String, int )
418
   * @see getWordEnded( String, int )
419
   */
420
  private int[] getWordAt( final String p, final int offset ) {
421
    return new int[]{ getWordBegan( p, offset ), getWordEnded( p, offset ) };
422
  }
423
424
  /**
425
   * Returns the index into s where a word begins.
426
   *
427
   * @param s Never null.
428
   * @param offset Index into s to begin searching backwards for a word
429
   * boundary.
430
   *
431
   * @return The index where a word begins.
432
   */
433
  private int getWordBegan( final String s, int offset ) {
434
    while( offset > 0 && isBoundary( s.charAt( offset - 1 ) ) ) {
435
      offset--;
436
    }
437
438
    return offset;
439
  }
440
441
  /**
442
   * Returns the index into s where a word ends.
443
   *
444
   * @param s Never null.
445
   * @param offset Index into s to begin searching forwards for a word boundary.
446
   *
447
   * @return The index where a word ends.
448
   */
449
  private int getWordEnded( final String s, int offset ) {
450
    final int length = s.length();
451
452
    while( offset < length && isBoundary( s.charAt( offset ) ) ) {
453
      offset++;
454
    }
455
456
    return offset;
457
  }
458
459
  /**
460
   * Returns true if the given character can be reasonably expected to be part
461
   * of a word, including punctuation marks.
462
   *
463
   * @param c The character to compare.
464
   *
465
   * @return false The character is a space character.
466
   */
467
  private boolean isBoundary( final char c ) {
468
    return !isWhitespace( c ) && !isPunctuation( c );
469
  }
470
471
  /**
472
   * Returns true if the given character is part of the set of Latin (English)
473
   * punctuation marks.
474
   *
475
   * @param c
476
   *
477
   * @return
478
   */
479
  private static boolean isPunctuation( final char c ) {
480
    return PUNCTUATION.indexOf( c ) != -1;
481
  }
482
483
  /**
484
   * Returns the text for the paragraph that contains the caret.
485
   *
486
   * @return A non-null string, possibly empty.
487
   */
488
  private String getCaretParagraph() {
489
    return getEditor().getText( getCurrentParagraph() );
490
  }
491
492
  /**
493
   * Returns true if the node has children that can be selected (i.e., any
494
   * non-leaves).
495
   *
496
   * @param <T> The type that the TreeItem contains.
497
   * @param node The node to test for terminality.
498
   *
499
   * @return true The node has one branch and its a leaf.
500
   */
501
  private <T> boolean isTerminal( final TreeItem<T> node ) {
502
    final ObservableList<TreeItem<T>> branches = node.getChildren();
503
504
    return branches.size() == 1 && branches.get( 0 ).isLeaf();
505
  }
506
507
  /**
508
   * Inserts text that the user typed at the current caret position, then
509
   * performs an autocomplete for the variable name.
510
   *
511
   * @param text The text to insert, never null.
512
   */
513
  private void typed( final String text ) {
514
    getEditor().replaceSelection( text );
515
    vModeAutocomplete();
516
  }
517
518
  /**
519
   * Called when the user presses either End or Enter key.
520
   */
521
  private void acceptPath() {
522
    final IndexRange range = getSelectionRange();
523
524
    if( range != null ) {
525
      final int rangeEnd = range.getEnd();
526
      final StyledTextArea textArea = getEditor();
527
      textArea.deselect();
528
      textArea.moveTo( rangeEnd );
529
    }
530
  }
531
532
  /**
533
   * Replaces the entirety of the existing path (from the initial caret
534
   * position) with the given path.
535
   *
536
   * @param oldPath The path to replace.
537
   * @param newPath The replacement path.
538
   */
539
  private void replacePath( final String oldPath, final String newPath ) {
540
    final StyledTextArea textArea = getEditor();
541
    final int posBegan = getInitialCaretPosition();
542
    final int posEnded = posBegan + oldPath.length();
543
544
    textArea.deselect();
545
    textArea.replaceText( posBegan, posEnded, newPath );
546
  }
547
548
  /**
549
   * Called when the user presses the Backspace key.
550
   */
551
  private void deleteSelection() {
552
    final StyledTextArea textArea = getEditor();
553
    textArea.replaceSelection( "" );
554
    textArea.deletePreviousChar();
555
  }
556
557
  /**
558
   * Cycles the selected text through the nodes.
559
   *
560
   * @param direction true - next; false - previous
561
   */
562
  private void cycleSelection( final boolean direction ) {
563
    final TreeItem<String> node = getCurrentNode();
564
565
    // Find the sibling for the current selection and replace the current
566
    // selection with the sibling's value
567
    TreeItem< String> cycled = direction
568
      ? node.nextSibling()
569
      : node.previousSibling();
570
571
    // When cycling at the end (or beginning) of the list, jump to the first
572
    // (or last) sibling depending on the cycle direction.
573
    if( cycled == null ) {
574
      cycled = direction ? getFirstSibling( node ) : getLastSibling( node );
575
    }
576
577
    final String path = getCurrentPath();
578
    final String cycledWord = cycled.getValue();
579
    final String word = getLastPathWord();
580
    final int index = path.indexOf( word );
581
    final String cycledPath = path.substring( 0, index ) + cycledWord;
582
583
    expand( cycled );
584
    replacePath( path, cycledPath );
585
  }
586
587
  /**
588
   * Cycles to the next sibling of the currently selected tree node.
589
   */
590
  private void cyclePathNext() {
591
    cycleSelection( true );
592
  }
593
594
  /**
595
   * Cycles to the previous sibling of the currently selected tree node.
596
   */
597
  private void cyclePathPrev() {
598
    cycleSelection( false );
599
  }
600
601
  /**
602
   * Returns the variable name (or as much as has been typed so far). Returns
603
   * all the characters from the initial caret column to the the first
604
   * whitespace character. This will return a path that contains zero or more
605
   * separators.
606
   *
607
   * @return A non-null string, possibly empty.
608
   */
609
  private String getCurrentPath() {
610
    final String s = extractTextChunk();
611
    final int length = s.length();
612
613
    int i = 0;
614
615
    while( i < length && !isWhitespace( s.charAt( i ) ) ) {
616
      i++;
617
    }
618
619
    return s.substring( 0, i );
620
  }
621
622
  private <T> ObservableList<TreeItem<T>> getSiblings(
623
    final TreeItem<T> item ) {
624
    final TreeItem<T> parent = item.getParent();
625
    return parent == null ? item.getChildren() : parent.getChildren();
626
  }
627
628
  private <T> TreeItem<T> getFirstSibling( final TreeItem<T> item ) {
629
    return getFirst( getSiblings( item ), item );
630
  }
631
632
  private <T> TreeItem<T> getLastSibling( final TreeItem<T> item ) {
633
    return getLast( getSiblings( item ), item );
634
  }
635
636
  /**
637
   * Returns the caret position as an offset into the text.
638
   *
639
   * @return A value from 0 to the length of the text (minus one).
640
   */
641
  private int getCurrentCaretPosition() {
642
    return getEditor().getCaretPosition();
643
  }
644
645
  /**
646
   * Returns the caret position within the current paragraph.
647
   *
648
   * @return A value from 0 to the length of the current paragraph.
649
   */
650
  private int getCurrentCaretColumn() {
651
    return getEditor().getCaretColumn();
652
  }
653
654
  /**
655
   * Returns the last word from the path.
656
   *
657
   * @return The last token.
658
   */
659
  private String getLastPathWord() {
660
    String path = getCurrentPath();
661
662
    int i = path.indexOf( SEPARATOR_CHAR );
663
664
    while( i > 0 ) {
665
      path = path.substring( i + 1 );
666
      i = path.indexOf( SEPARATOR_CHAR );
667
    }
668
669
    return path;
670
  }
671
672
  /**
673
   * Returns text from the initial caret position until some arbitrarily long
674
   * number of characters. The number of characters extracted will be
675
   * getMaxVarLength, or fewer, depending on how many characters remain to be
676
   * extracted. The result from this method is trimmed to the first whitespace
677
   * character.
678
   *
679
   * @return A chunk of text that includes all the words representing a path,
680
   * and then some.
681
   */
682
  private String extractTextChunk() {
683
    final StyledTextArea textArea = getEditor();
684
    final int textBegan = getInitialCaretPosition();
685
    final int remaining = textArea.getLength() - textBegan;
686
    final int textEnded = min( remaining, getMaxVarLength() );
687
688
    try {
689
      return textArea.getText( textBegan, textEnded );
690
    }
691
    catch( final Exception e ) {
692
      return textArea.getText();
693
    }
694
  }
695
696
  /**
697
   * Returns the node for the current path.
698
   */
699
  private TreeItem<String> getCurrentNode() {
700
    return findNode( getCurrentPath() );
701
  }
702
703
  /**
704
   * Finds the node that most closely matches the given path.
705
   *
706
   * @param path The path that represents a node.
707
   *
708
   * @return The node for the path, or the root node if the path could not be
709
   * found, but never null.
710
   */
711
  private TreeItem<String> findNode( final String path ) {
712
    return getDefinitionPane().findNode( path );
713
  }
714
715
  /**
716
   * Finds the first leaf having a value that starts with the given text.
717
   *
718
   * @param text The text to find in the definition tree.
719
   *
720
   * @return The leaf that starts with the given text, or null if not found.
721
   */
722
  private VariableTreeItem<String> findLeaf( final String text ) {
723
    return getDefinitionPane().findLeaf( text, false );
724
  }
725
726
  /**
727
   * Finds the first leaf having a value that starts with the given text, or
728
   * contains the text if contains is true.
729
   *
730
   * @param text The text to find in the definition tree.
731
   * @param contains Set true to perform a substring match after a starts with
732
   * match.
733
   *
734
   * @return The leaf that starts with the given text, or null if not found.
735
   */
736
  private VariableTreeItem<String> findLeaf(
737
    final String text,
738
    final boolean contains ) {
739
    return getDefinitionPane().findLeaf( text, contains );
740
  }
741
742
  /**
743
   * Used to ignore typed keys in favour of trapping pressed keys.
744
   *
745
   * @param e The key that was typed.
746
   */
747
  private void vModeKeyTyped( KeyEvent e ) {
748
    e.consume();
749
  }
750
751
  /**
752
   * Used to lazily initialize the keyboard map.
753
   *
754
   * @return Mappings for keyTyped and keyPressed.
755
   */
756
  protected InputMap<InputEvent> createKeyboardMap() {
757
    return sequence(
758
      consume( keyTyped(), this::vModeKeyTyped ),
759
      consume( keyPressed(), this::vModeKeyPressed )
760
    );
761
  }
762
763
  private InputMap<InputEvent> getKeyboardMap() {
764
    if( this.keyboardMap == null ) {
765
      this.keyboardMap = createKeyboardMap();
766
    }
767
768
    return this.keyboardMap;
769
  }
770
771
  /**
772
   * Collapses the tree then expands and selects the given node.
773
   *
774
   * @param node The node to expand.
775
   */
776
  private void expand( final TreeItem<String> node ) {
777
    final DefinitionPane pane = getDefinitionPane();
778
    pane.collapse();
779
    pane.expand( node );
780
    pane.select( node );
781
  }
782
783
  /**
784
   * Returns true iff the key code the user typed can be used as part of a YAML
785
   * variable name.
786
   *
787
   * @param keyEvent Keyboard key press event information.
788
   *
789
   * @return true The key is a value that can be inserted into the text.
790
   */
791
  private boolean isVariableNameKey( final KeyEvent keyEvent ) {
792
    final KeyCode kc = keyEvent.getCode();
793
794
    return (kc.isLetterKey()
795
      || kc.isDigitKey()
796
      || (keyEvent.isShiftDown() && kc == MINUS))
797
      && !keyEvent.isControlDown();
798
  }
799
800
  /**
801
   * Starts to capture user input events.
802
   */
803
  private void vModeStart() {
804
    addEventListener( getKeyboardMap() );
805
  }
806
807
  /**
808
   * Restores capturing of user input events to the previous event listener.
809
   * Also asks the processing chain to modify the variable text into a
810
   * machine-readable variable based on the format required by the file type.
811
   * For example, a Markdown file (.md) will substitute a $VAR$ name while an R
812
   * file (.Rmd, .Rxml) will use `r#xVAR`.
813
   */
814
  private void vModeStop() {
815
    removeEventListener( getKeyboardMap() );
816
  }
817
818
  /**
819
   * Returns a variable decorator that corresponds to the given file type.
820
   *
821
   * @return
822
   */
823
  private VariableDecorator getVariableDecorator() {
824
    return VariableNameDecoratorFactory.newInstance( getFilename() );
825
  }
826
827
  private Path getFilename() {
828
    return getFileEditorTab().getPath();
829
  }
830
831
  /**
832
   * Returns the index where the two strings diverge.
833
   *
834
   * @param s1 The string that could be a substring of s2, null allowed.
835
   * @param s2 The string that could be a substring of s1, null allowed.
836
   *
837
   * @return NO_DIFFERENCE if the strings are the same, otherwise the index
838
   * where they differ.
839
   */
840
  @SuppressWarnings( "StringEquality" )
841
  private int difference( final CharSequence s1, final CharSequence s2 ) {
842
    if( s1 == s2 ) {
843
      return NO_DIFFERENCE;
844
    }
845
846
    if( s1 == null || s2 == null ) {
847
      return 0;
848
    }
849
850
    int i = 0;
851
    final int limit = min( s1.length(), s2.length() );
852
853
    while( i < limit && s1.charAt( i ) == s2.charAt( i ) ) {
854
      i++;
855
    }
856
857
    // If one string was shorter than the other, that's where they differ.
858
    return i;
859
  }
860
861
  private EditorPane getEditorPane() {
862
    return getFileEditorTab().getEditorPane();
863
  }
864
865
  /**
866
   * Delegates to the file editor pane, and, ultimately, to its text area.
867
   */
868
  private <T extends Event, U extends T> void addEventListener(
869
    final EventPattern<? super T, ? extends U> event,
870
    final Consumer<? super U> consumer ) {
871
    getEditorPane().addEventListener( event, consumer );
872
  }
873
874
  /**
875
   * Delegates to the file editor pane, and, ultimately, to its text area.
876
   *
877
   * @param map The map of methods to events.
878
   */
879
  private void addEventListener( final InputMap<InputEvent> map ) {
880
    getEditorPane().addEventListener( map );
881
  }
882
883
  private void removeEventListener( final InputMap<InputEvent> map ) {
884
    getEditorPane().removeEventListener( map );
885
  }
886
887
  /**
888
   * Returns the position of the caret when variable mode editing was requested.
889
   *
890
   * @return The variable mode caret position.
891
   */
892
  private int getInitialCaretPosition() {
893
    return this.initialCaretPosition;
894
  }
895
896
  /**
897
   * Sets the position of the caret when variable mode editing was requested.
898
   * Stores the current position because only the text that comes afterwards is
899
   * a suitable variable reference.
900
   *
901
   * @return The variable mode caret position.
902
   */
903
  private void setInitialCaretPosition() {
904
    this.initialCaretPosition = getEditor().getCaretPosition();
905
  }
906
907
  private StyledTextArea getEditor() {
908
    return getEditorPane().getEditor();
909
  }
910
911
  public FileEditorTab getFileEditorTab() {
912
    return this.tab;
913
  }
914
915
  public void setFileEditorTab( final FileEditorTab editorTab ) {
916
    this.tab = editorTab;
917
  }
918
919
  private DefinitionPane getDefinitionPane() {
920
    return this.definitionPane;
921
  }
922
923
  private void setDefinitionPane( final DefinitionPane definitionPane ) {
924
    this.definitionPane = definitionPane;
925
  }
926
927
  private IndexRange getSelectionRange() {
928
    return getEditor().getSelection();
929
  }
930
931
  /**
932
   * Don't look ahead too far when trying to find the end of a node.
933
   *
934
   * @return 512 by default.
935
   */
936
  private int getMaxVarLength() {
937
    return getSettings().getSetting(
938
      "editor.variable.maxLength", DEFAULT_MAX_VAR_LENGTH );
939
  }
940
941
  private Settings getSettings() {
942
    return this.settings;
943
  }
944
945
  private void setPanelEventHandler( final EventHandler<MouseEvent> eventHandler ) {
946
    this.panelEventHandler = eventHandler;
947
  }
948
949
  private synchronized EventHandler<MouseEvent> getPanelEventHandler() {
950
    if( this.panelEventHandler == null ) {
951
      this.panelEventHandler = createPanelEventHandler();
952
    }
953
954
    return this.panelEventHandler;
955
  }
956
957
  private EventHandler<MouseEvent> createPanelEventHandler() {
958
    return new PanelEventHandler();
959
  }
960
961
  /**
962
   * Responsible for handling double-click events on the definition pane.
963
   */
964
  private class PanelEventHandler implements EventHandler<MouseEvent> {
965
966
    public PanelEventHandler() {
967
    }
968
969
    @Override
970
    public void handle( final MouseEvent event ) {
971
      final Object source = event.getSource();
972
973
      if( source instanceof TreeView ) {
974
        final TreeView tree = (TreeView)source;
975
        final TreeItem item = (TreeItem)tree.getSelectionModel().getSelectedItem();
976
977
        if( item instanceof VariableTreeItem ) {
978
          final VariableTreeItem var = (VariableTreeItem)item;
979
          final String text = decorate( var.toPath() );
980
981
          replaceSelection( text );
982
        }
983
      }
984
    }
930985
  }
931986
}
M src/main/java/com/scrivenvar/preview/HTMLPreviewPane.java
122122
    execute( getScrollScript() );
123123
  }
124
124
  
125125
  /**
126126
   * Returns the JavaScript used to scroll the WebView pane.
M src/main/java/com/scrivenvar/processors/text/AhoCorasickReplacer.java
5353
    // The TrieBuilder should only match whole words and ignore overlaps (there
5454
    // shouldn't be any).
55
    final TrieBuilder builder = builder().onlyWholeWords().removeOverlaps();
55
    final TrieBuilder builder = builder().onlyWholeWords().ignoreOverlaps();
5656
5757
    for( final String key : keys( map ) ) {