| 1 | Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com> | |
| 1 | Copyright 2017 White Magic Software, Ltd. | |
| 2 | 2 | All rights reserved. |
| 3 | 3 |
| 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 | ||
| 1 | 35 |
| 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 | ||
| 1 | 240 |
| 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 | ||
| 1 | 60 |
| 1 | 1 | # Introduction |
| 2 | 2 | |
| 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. | |
| 4 | 4 | |
| 5 | # Software Requirements | |
| 5 | # Variable Definitions | |
| 6 | 6 | |
| 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: | |
| 8 | 8 | |
| 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 | ``` | |
| 21 | 12 | |
| 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: | |
| 23 | 14 | |
| 24 | The required software packages are installed. | |
| 15 | ``` | |
| 16 | key_1: Value 1 | |
| 17 | key_2: Value 2 | |
| 18 | ``` | |
| 25 | 19 | |
| 26 | # Markdown | |
| 20 | Variables can reference other variables by enclosing the key name within dollar symbols: | |
| 27 | 21 | |
| 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 | ||
| 31 | 114 |
| 16 | 16 | |
| 17 | 17 | dependencies { |
| 18 | compile 'org.controlsfx:controlsfx:8.40.12' | |
| 18 | compile 'org.controlsfx:controlsfx:8.40.14' | |
| 19 | 19 | compile 'org.fxmisc.wellbehaved:wellbehavedfx:0.3' |
| 20 | 20 | compile 'org.fxmisc.richtext:richtextfx:0.8.1' |
| 21 | 21 | compile 'com.miglayout:miglayout-javafx:5.0' |
| 22 | 22 | compile 'de.jensd:fontawesomefx-fontawesome:4.5.0' |
| 23 | 23 | 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' | |
| 32 | 32 | compile 'org.yaml:snakeyaml:1.19' |
| 33 | 33 | 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' | |
| 35 | 35 | compile 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3' |
| 36 | 36 | 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') | |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | version = '1.3.2' | |
| 40 | version = '1.3.3' | |
| 41 | 41 | applicationName = 'scrivenvar' |
| 42 | 42 | mainClassName = 'com.scrivenvar.Main' |
| 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. | |
| 1 | 25 |
| 119 | 119 | private Map<FileEditorTab, Processor<String>> processors; |
| 120 | 120 | |
| 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 ); | |
| 963 | 1050 | |
| 964 | 1051 | menuBar = new MenuBar( fileMenu, editMenu, insertMenu, toolsMenu, helpMenu ); |
| 37 | 37 | import javafx.collections.ObservableList; |
| 38 | 38 | import javafx.event.EventHandler; |
| 39 | import javafx.event.EventType; | |
| 39 | 40 | import javafx.scene.Node; |
| 40 | 41 | import javafx.scene.control.MultipleSelectionModel; |
| 41 | 42 | import javafx.scene.control.SelectionMode; |
| 42 | 43 | import javafx.scene.control.TreeItem; |
| 43 | 44 | import javafx.scene.control.TreeView; |
| 44 | 45 | import javafx.scene.input.MouseButton; |
| 46 | import static javafx.scene.input.MouseButton.PRIMARY; | |
| 45 | 47 | import javafx.scene.input.MouseEvent; |
| 48 | import static javafx.scene.input.MouseEvent.MOUSE_CLICKED; | |
| 46 | 49 | |
| 47 | 50 | /** |
| ... | ||
| 73 | 76 | * Allows observers to receive double-click events on the tree view. |
| 74 | 77 | * |
| 75 | * @param handler The handler that | |
| 78 | * @param handler The handler that will receive double-click events. | |
| 76 | 79 | */ |
| 77 | 80 | public void addBranchSelectedListener( |
| 78 | 81 | 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(); | |
| 79 | 87 | |
| 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(); | |
| 84 | 94 | } |
| 95 | } ); | |
| 96 | } | |
| 85 | 97 | |
| 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 ); | |
| 89 | 106 | } |
| 90 | 107 | |
| ... | ||
| 182 | 199 | String path = word; |
| 183 | 200 | |
| 201 | // Current tree item. | |
| 184 | 202 | TreeItem<String> cItem = getTreeRoot(); |
| 203 | ||
| 204 | // Previous tree item. | |
| 185 | 205 | TreeItem<String> pItem = cItem; |
| 186 | 206 | |
| 38 | 38 | import static com.scrivenvar.util.Lists.getFirst; |
| 39 | 39 | import static com.scrivenvar.util.Lists.getLast; |
| 40 | import static java.lang.Character.isSpaceChar; | |
| 41 | import static java.lang.Character.isWhitespace; | |
| 42 | import static java.lang.Math.min; | |
| 43 | import java.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 | } | |
| 930 | 985 | } |
| 931 | 986 | } |
| 122 | 122 | execute( getScrollScript() ); |
| 123 | 123 | } |
| 124 | ||
| 124 | ||
| 125 | 125 | /** |
| 126 | 126 | * Returns the JavaScript used to scroll the WebView pane. |
| 53 | 53 | // The TrieBuilder should only match whole words and ignore overlaps (there |
| 54 | 54 | // shouldn't be any). |
| 55 | final TrieBuilder builder = builder().onlyWholeWords().removeOverlaps(); | |
| 55 | final TrieBuilder builder = builder().onlyWholeWords().ignoreOverlaps(); | |
| 56 | 56 | |
| 57 | 57 | for( final String key : keys( map ) ) { |