| 3 | 3 | <component name="ChangeListManager"> |
| 4 | 4 | <list default="true" id="3dcf7c8f-87b5-4d25-a804-39da40a621b8" name="Default Changelist" comment=""> |
| 5 | <change afterPath="$PROJECT_DIR$/.idea/rGraphicsSettings.xml" afterDir="false" /> | |
| 6 | <change afterPath="$PROJECT_DIR$/.idea/rpackages.xml" afterDir="false" /> | |
| 7 | <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> | |
| 8 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/Constants.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/Constants.java" afterDir="false" /> | |
| 9 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/MainWindow.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/MainWindow.java" afterDir="false" /> | |
| 10 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/Messages.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/Messages.java" afterDir="false" /> | |
| 11 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/dialogs/RScriptDialog.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/dialogs/RScriptDialog.java" afterDir="false" /> | |
| 12 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/processors/InlineRProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/processors/InlineRProcessor.java" afterDir="false" /> | |
| 13 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/processors/ProcessorFactory.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/processors/ProcessorFactory.java" afterDir="false" /> | |
| 14 | <change beforePath="$PROJECT_DIR$/src/main/resources/com/scrivenvar/messages.properties" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/resources/com/scrivenvar/messages.properties" afterDir="false" /> | |
| 5 | <change beforePath="$PROJECT_DIR$/src/main/java/com/scrivenvar/FileEditorTab.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/scrivenvar/FileEditorTab.java" afterDir="false" /> | |
| 15 | 6 | </list> |
| 16 | 7 | <option name="SHOW_DIALOG" value="false" /> |
| ... | ||
| 72 | 63 | <component name="PropertiesComponent"> |
| 73 | 64 | <property name="ASKED_ADD_EXTERNAL_FILES" value="true" /> |
| 65 | <property name="RunOnceActivity.OpenProjectViewOnStart" value="true" /> | |
| 66 | <property name="RunOnceActivity.ShowReadmeOnStart" value="true" /> | |
| 74 | 67 | <property name="SHARE_PROJECT_CONFIGURATION_FILES" value="true" /> |
| 75 | 68 | <property name="com.android.tools.idea.instantapp.provision.ProvisionBeforeRunTaskProvider.myTimeStamp" value="1541653415064" /> |
| ... | ||
| 173 | 166 | </state> |
| 174 | 167 | <state x="635" y="363" width="376" height="578" key="#com.intellij.ide.util.MemberChooser/0.28.2560.1529@0.28.2560.1529" timestamp="1589658771205" /> |
| 175 | <state x="468" y="28" width="711" height="1526" key="#com.intellij.refactoring.rename.AutomaticRenamingDialog" timestamp="1589658744105"> | |
| 168 | <state x="468" y="28" width="711" height="1526" key="#com.intellij.refactoring.rename.AutomaticRenamingDialog" timestamp="1590855861709"> | |
| 176 | 169 | <screen x="0" y="28" width="2560" height="1529" /> |
| 177 | 170 | </state> |
| 178 | <state x="468" y="28" width="711" height="1526" key="#com.intellij.refactoring.rename.AutomaticRenamingDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1589658744105" /> | |
| 171 | <state x="468" y="28" width="711" height="1526" key="#com.intellij.refactoring.rename.AutomaticRenamingDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1590855861709" /> | |
| 179 | 172 | <state x="610" y="411" width="426" height="481" key="FileChooserDialogImpl" timestamp="1589659107517"> |
| 180 | 173 | <screen x="0" y="28" width="2560" height="1529" /> |
| 181 | 174 | </state> |
| 182 | 175 | <state x="610" y="411" width="426" height="481" key="FileChooserDialogImpl/0.28.2560.1529@0.28.2560.1529" timestamp="1589659107517" /> |
| 183 | <state width="1573" height="321" key="GridCell.Tab.0.bottom" timestamp="1589671859570"> | |
| 176 | <state width="1573" height="321" key="GridCell.Tab.0.bottom" timestamp="1590863344981"> | |
| 184 | 177 | <screen x="0" y="28" width="2560" height="1529" /> |
| 185 | 178 | </state> |
| 186 | <state width="1573" height="321" key="GridCell.Tab.0.bottom/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859570" /> | |
| 187 | <state width="1573" height="321" key="GridCell.Tab.0.center" timestamp="1589671859569"> | |
| 179 | <state width="1573" height="321" key="GridCell.Tab.0.bottom/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344981" /> | |
| 180 | <state width="1573" height="321" key="GridCell.Tab.0.center" timestamp="1590863344980"> | |
| 188 | 181 | <screen x="0" y="28" width="2560" height="1529" /> |
| 189 | 182 | </state> |
| 190 | <state width="1573" height="321" key="GridCell.Tab.0.center/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859569" /> | |
| 191 | <state width="1573" height="321" key="GridCell.Tab.0.left" timestamp="1589671859569"> | |
| 183 | <state width="1573" height="321" key="GridCell.Tab.0.center/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344980" /> | |
| 184 | <state width="1573" height="321" key="GridCell.Tab.0.left" timestamp="1590863344980"> | |
| 192 | 185 | <screen x="0" y="28" width="2560" height="1529" /> |
| 193 | 186 | </state> |
| 194 | <state width="1573" height="321" key="GridCell.Tab.0.left/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859569" /> | |
| 195 | <state width="1573" height="321" key="GridCell.Tab.0.right" timestamp="1589671859570"> | |
| 187 | <state width="1573" height="321" key="GridCell.Tab.0.left/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344980" /> | |
| 188 | <state width="1573" height="321" key="GridCell.Tab.0.right" timestamp="1590863344981"> | |
| 196 | 189 | <screen x="0" y="28" width="2560" height="1529" /> |
| 197 | 190 | </state> |
| 198 | <state width="1573" height="321" key="GridCell.Tab.0.right/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859570" /> | |
| 199 | <state width="1573" height="396" key="GridCell.Tab.1.bottom" timestamp="1589671859555"> | |
| 191 | <state width="1573" height="321" key="GridCell.Tab.0.right/0.28.2560.1529@0.28.2560.1529" timestamp="1590863344981" /> | |
| 192 | <state width="1573" height="396" key="GridCell.Tab.1.bottom" timestamp="1590858353845"> | |
| 200 | 193 | <screen x="0" y="28" width="2560" height="1529" /> |
| 201 | 194 | </state> |
| 202 | <state width="1573" height="396" key="GridCell.Tab.1.bottom/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859555" /> | |
| 203 | <state width="1573" height="396" key="GridCell.Tab.1.center" timestamp="1589671859555"> | |
| 195 | <state width="1573" height="396" key="GridCell.Tab.1.bottom/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" /> | |
| 196 | <state width="1573" height="396" key="GridCell.Tab.1.center" timestamp="1590858353845"> | |
| 204 | 197 | <screen x="0" y="28" width="2560" height="1529" /> |
| 205 | 198 | </state> |
| 206 | <state width="1573" height="396" key="GridCell.Tab.1.center/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859555" /> | |
| 207 | <state width="1573" height="396" key="GridCell.Tab.1.left" timestamp="1589671859554"> | |
| 199 | <state width="1573" height="396" key="GridCell.Tab.1.center/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" /> | |
| 200 | <state width="1573" height="396" key="GridCell.Tab.1.left" timestamp="1590858353845"> | |
| 208 | 201 | <screen x="0" y="28" width="2560" height="1529" /> |
| 209 | 202 | </state> |
| 210 | <state width="1573" height="396" key="GridCell.Tab.1.left/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859554" /> | |
| 211 | <state width="1573" height="396" key="GridCell.Tab.1.right" timestamp="1589671859555"> | |
| 203 | <state width="1573" height="396" key="GridCell.Tab.1.left/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" /> | |
| 204 | <state width="1573" height="396" key="GridCell.Tab.1.right" timestamp="1590858353845"> | |
| 212 | 205 | <screen x="0" y="28" width="2560" height="1529" /> |
| 213 | 206 | </state> |
| 214 | <state width="1573" height="396" key="GridCell.Tab.1.right/0.28.2560.1529@0.28.2560.1529" timestamp="1589671859555" /> | |
| 207 | <state width="1573" height="396" key="GridCell.Tab.1.right/0.28.2560.1529@0.28.2560.1529" timestamp="1590858353845" /> | |
| 215 | 208 | <state x="324" y="288" key="SettingsEditor" timestamp="1589576619807"> |
| 216 | 209 | <screen x="0" y="28" width="2560" height="1529" /> |
| 217 | 210 | </state> |
| 218 | 211 | <state x="324" y="288" key="SettingsEditor/0.28.2560.1529@0.28.2560.1529" timestamp="1589576619807" /> |
| 219 | 212 | <state x="1071" y="397" width="1417" height="979" key="com.intellij.history.integration.ui.views.FileHistoryDialog" timestamp="1589661186060"> |
| 220 | 213 | <screen x="0" y="28" width="2560" height="1529" /> |
| 221 | 214 | </state> |
| 222 | 215 | <state x="1071" y="397" width="1417" height="979" key="com.intellij.history.integration.ui.views.FileHistoryDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1589661186060" /> |
| 223 | <state x="531" y="261" width="586" height="753" key="find.popup" timestamp="1589669468040"> | |
| 216 | <state x="714" y="633" key="com.intellij.openapi.vcs.update.UpdateOrStatusOptionsDialogupdate-v2" timestamp="1590804130551"> | |
| 224 | 217 | <screen x="0" y="28" width="2560" height="1529" /> |
| 225 | 218 | </state> |
| 226 | <state x="531" y="261" width="586" height="753" key="find.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1589669468040" /> | |
| 219 | <state x="714" y="633" key="com.intellij.openapi.vcs.update.UpdateOrStatusOptionsDialogupdate-v2/0.28.2560.1529@0.28.2560.1529" timestamp="1590804130551" /> | |
| 220 | <state x="531" y="261" width="586" height="753" key="find.popup" timestamp="1590803334947"> | |
| 221 | <screen x="0" y="28" width="2560" height="1529" /> | |
| 222 | </state> | |
| 223 | <state x="531" y="261" width="586" height="753" key="find.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1590803334947" /> | |
| 227 | 224 | <state x="533" y="414" width="581" height="476" key="refactoring.ChangeSignatureDialog" timestamp="1589663937037"> |
| 228 | 225 | <screen x="0" y="28" width="2560" height="1529" /> |
| 229 | 226 | </state> |
| 230 | 227 | <state x="533" y="414" width="581" height="476" key="refactoring.ChangeSignatureDialog/0.28.2560.1529@0.28.2560.1529" timestamp="1589663937037" /> |
| 231 | 228 | <state x="490" y="304" key="run.anything.popup" timestamp="1589657324666"> |
| 232 | 229 | <screen x="0" y="28" width="2560" height="1529" /> |
| 233 | 230 | </state> |
| 234 | 231 | <state x="490" y="304" key="run.anything.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1589657324666" /> |
| 235 | <state x="490" y="327" width="672" height="678" key="search.everywhere.popup" timestamp="1589669442714"> | |
| 232 | <state x="490" y="327" width="672" height="678" key="search.everywhere.popup" timestamp="1590858282498"> | |
| 236 | 233 | <screen x="0" y="28" width="2560" height="1529" /> |
| 237 | 234 | </state> |
| 238 | <state x="490" y="327" width="672" height="678" key="search.everywhere.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1589669442714" /> | |
| 235 | <state x="490" y="327" width="672" height="678" key="search.everywhere.popup/0.28.2560.1529@0.28.2560.1529" timestamp="1590858282498" /> | |
| 239 | 236 | </component> |
| 240 | 237 | <component name="XDebuggerManager"> |
| 241 | <breakpoint-manager> | |
| 242 | <breakpoints> | |
| 243 | <line-breakpoint enabled="true" type="java-line"> | |
| 244 | <url>file://$PROJECT_DIR$/src/main/java/com/scrivenvar/MainWindow.java</url> | |
| 245 | <line>410</line> | |
| 246 | <option name="timeStamp" value="4" /> | |
| 247 | </line-breakpoint> | |
| 248 | </breakpoints> | |
| 249 | </breakpoint-manager> | |
| 238 | <watches-manager> | |
| 239 | <configuration name="Remote"> | |
| 240 | <watch expression="getMatcher().matches( ( new File ("./untitled.md")).toPath() )" language="JAVA" /> | |
| 241 | </configuration> | |
| 242 | </watches-manager> | |
| 250 | 243 | </component> |
| 251 | 244 | <component name="masterDetails"> |
| 1 | R Functions | |
| 2 | === | |
| 1 | # R Functions | |
| 3 | 2 | |
| 4 | 3 | Import the files in this directory into the application, which include: |
| 5 | 4 | |
| 6 | * pluralise.R | |
| 5 | * pluralize.R | |
| 7 | 6 | * possessive.R |
| 8 | 7 | |
| 9 | pluralise.R | |
| 10 | === | |
| 8 | # pluralize.R | |
| 11 | 9 | |
| 12 | 10 | 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 | 11 | |
| 14 | Usage | |
| 15 | --- | |
| 16 | Example usages of the pluralise function include: | |
| 12 | ## Usage | |
| 17 | 13 | |
| 18 | `r#pluralise( 'mouse' )` - mice | |
| 19 | `r#pluralise( 'buzz' )` - buzzes | |
| 20 | `r#pluralise( 'bus' )` - busses | |
| 14 | Example usages of the pluralize function include: | |
| 21 | 15 | |
| 22 | possessive.R | |
| 23 | === | |
| 16 | `r#pluralize( 'mouse' )` - mice | |
| 17 | `r#pluralize( 'buzz' )` - buzzes | |
| 18 | `r#pluralize( 'bus' )` - busses | |
| 19 | ||
| 20 | # possessive.R | |
| 24 | 21 | |
| 25 | 22 | This file defines a function that applies possessives to English words. |
| 26 | 23 | |
| 27 | Usage | |
| 28 | --- | |
| 24 | ## Usage | |
| 25 | ||
| 29 | 26 | Example usages of the possessive function include: |
| 30 | 27 | |
| 31 | 28 | `r#pos( 'Ross' )` - Ross' |
| 32 | 29 | `r#pos( 'Ruby' )` - Ruby's |
| 33 | 30 | `r#pos( 'Lois' )` - Lois' |
| 31 | `r#pos( 'my' )` - mine | |
| 32 | `r#pos( 'Your' )` - Yours | |
| 34 | 33 | |
| 35 | 34 |
| 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 | ||
| 240 | 1 |
| 1 | # ----------------------------------------------------------------------------- | |
| 2 | # Copyright 2020, White Magic Software, Ltd. | |
| 3 | # | |
| 4 | # Permission is hereby granted, free of charge, to any person obtaining | |
| 5 | # a copy of this software and associated documentation files (the | |
| 6 | # "Software"), to deal in the Software without restriction, including | |
| 7 | # without limitation the rights to use, copy, modify, merge, publish, | |
| 8 | # distribute, sublicense, and/or sell copies of the Software, and to | |
| 9 | # permit persons to whom the Software is furnished to do so, subject to | |
| 10 | # the following conditions: | |
| 11 | # | |
| 12 | # The above copyright notice and this permission notice shall be | |
| 13 | # included in all copies or substantial portions of the Software. | |
| 14 | # | |
| 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
| 18 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
| 19 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
| 20 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| 21 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 22 | # ----------------------------------------------------------------------------- | |
| 23 | ||
| 24 | # ----------------------------------------------------------------------------- | |
| 25 | # See Damian Conway's "An Algorithmic Approach to English Pluralization": | |
| 26 | # http://goo.gl/oRL4MP | |
| 27 | # See Oliver Glerke's Evo Inflector: https://github.com/atteo/evo-inflector/ | |
| 28 | # See Shevek's Pluralizer: https://github.com/shevek/linguistics/ | |
| 29 | # See also: http://www.freevectors.net/assets/files/plural.txt | |
| 30 | # ----------------------------------------------------------------------------- | |
| 31 | pluralize <- function( s, n ) { | |
| 32 | result <- s | |
| 33 | ||
| 34 | # Partial implementation of Conway's algorithm for nouns. | |
| 35 | if( n != 1 ) { | |
| 36 | if( pl.noninflective( s ) || | |
| 37 | pl.suffix( "es", s ) || | |
| 38 | pl.suffix( "fish", s ) || | |
| 39 | pl.suffix( "ois", s ) || | |
| 40 | pl.suffix( "sheep", s ) || | |
| 41 | pl.suffix( "deer", s ) || | |
| 42 | pl.suffix( "pox", s ) || | |
| 43 | pl.suffix( "[A-Z].*ese", s ) || | |
| 44 | pl.suffix( "itis", s ) ) { | |
| 45 | # 1. Retain non-inflective user-mapped noun as is. | |
| 46 | # 2. Retain non-inflective plural as is. | |
| 47 | result <- s | |
| 48 | } | |
| 49 | else if( pl.is.irregular.pl( s ) ) { | |
| 50 | # 4. Change irregular plurals based on mapping. | |
| 51 | result <- pl.irregular.pl( s ) | |
| 52 | } | |
| 53 | else if( pl.is.irregular.es( s ) ) { | |
| 54 | # x. From Shevek's | |
| 55 | result <- pl.inflect( s, "", "es" ) | |
| 56 | } | |
| 57 | else if( pl.suffix( "man", s ) ) { | |
| 58 | # 5. For -man, change -an to -en | |
| 59 | result <- pl.inflect( s, "an", "en" ) | |
| 60 | } | |
| 61 | else if( pl.suffix( "[lm]ouse", s ) ) { | |
| 62 | # 5. For [lm]ouse, change -ouse to -ice | |
| 63 | result <- pl.inflect( s, "ouse", "ice" ) | |
| 64 | } | |
| 65 | else if( pl.suffix( "tooth", s ) ) { | |
| 66 | # 5. For -tooth, change -ooth to -eeth | |
| 67 | result <- pl.inflect( s, "ooth", "eeth" ) | |
| 68 | } | |
| 69 | else if( pl.suffix( "goose", s ) ) { | |
| 70 | # 5. For -goose, change -oose to -eese | |
| 71 | result <- pl.inflect( s, "oose", "eese" ) | |
| 72 | } | |
| 73 | else if( pl.suffix( "foot", s ) ) { | |
| 74 | # 5. For -foot, change -oot to -eet | |
| 75 | result <- pl.inflect( s, "oot", "eet" ) | |
| 76 | } | |
| 77 | else if( pl.suffix( "zoon", s ) ) { | |
| 78 | # 5. For -zoon, change -on to -a | |
| 79 | result <- pl.inflect( s, "on", "a" ) | |
| 80 | } | |
| 81 | else if( pl.suffix( "[csx]is", s ) ) { | |
| 82 | # 5. Change -cis, -sis, -xis to -es | |
| 83 | result <- pl.inflect( s, "is", "es" ) | |
| 84 | } | |
| 85 | else if( pl.suffix( "([cs]h|ss|zz|x|s)", s ) ) { | |
| 86 | # 8. Change -ch, -sh, -ss, -zz, -x, -s to -es | |
| 87 | result <- pl.inflect( s, "", "es" ) | |
| 88 | } | |
| 89 | else if( pl.suffix( "([aeo]lf|[^d]eaf|arf)", s ) ) { | |
| 90 | # 9. Change -f to -ves | |
| 91 | result <- pl.inflect( s, "f", "ves" ) | |
| 92 | } | |
| 93 | else if( pl.suffix( "[nlw]ife", s ) ) { | |
| 94 | # 10. Change -fe to -ves | |
| 95 | result <- pl.inflect( s, "fe", "ves" ) | |
| 96 | } | |
| 97 | else if( pl.suffix( "[aeiou]y", s ) ) { | |
| 98 | # 11. Change -[aeiou]y to -ys | |
| 99 | result <- pl.inflect( s, "", "s" ) | |
| 100 | } | |
| 101 | else if( pl.suffix( "y", s ) ) { | |
| 102 | # 12. Change -y to -ies | |
| 103 | result <- pl.inflect( s, "y", "ies" ) | |
| 104 | } | |
| 105 | else if( pl.suffix( "z", s ) ) { | |
| 106 | # x. Change -z to -zzes | |
| 107 | result <- pl.inflect( s, "", "zes" ) | |
| 108 | } | |
| 109 | else { | |
| 110 | # 13. Default plural: add -s | |
| 111 | result <- pl.inflect( s, "", "s" ) | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | result | |
| 116 | } | |
| 117 | ||
| 118 | # ----------------------------------------------------------------------------- | |
| 119 | # Returns the given string (s) with its suffix replaced by r. | |
| 120 | # ----------------------------------------------------------------------------- | |
| 121 | pl.inflect <- function( s, suffix, r ) { | |
| 122 | gsub( paste( suffix, "$", sep="" ), r, s ) | |
| 123 | } | |
| 124 | ||
| 125 | # ----------------------------------------------------------------------------- | |
| 126 | # Answers whether the given string (s) has the given ending. | |
| 127 | # ----------------------------------------------------------------------------- | |
| 128 | pl.suffix <- function( ending, s ) { | |
| 129 | grepl( paste( ending, "$", sep="" ), s ) | |
| 130 | } | |
| 131 | ||
| 132 | # ----------------------------------------------------------------------------- | |
| 133 | # Answers whether the given string (s) is a noninflective noun. | |
| 134 | # ----------------------------------------------------------------------------- | |
| 135 | pl.noninflective <- function( s ) { | |
| 136 | v <- c( | |
| 137 | "aircraft", | |
| 138 | "Bhutanese", | |
| 139 | "bison", | |
| 140 | "bream", | |
| 141 | "Burmese", | |
| 142 | "carp", | |
| 143 | "chassis", | |
| 144 | "Chinese", | |
| 145 | "clippers", | |
| 146 | "cod", | |
| 147 | "contretemps", | |
| 148 | "corps", | |
| 149 | "debris", | |
| 150 | "djinn", | |
| 151 | "eland", | |
| 152 | "elk", | |
| 153 | "flounder", | |
| 154 | "fracas", | |
| 155 | "gallows", | |
| 156 | "graffiti", | |
| 157 | "headquarters", | |
| 158 | "high-jinks", | |
| 159 | "homework", | |
| 160 | "hovercraft", | |
| 161 | "innings", | |
| 162 | "Japanese", | |
| 163 | "Lebanese", | |
| 164 | "mackerel", | |
| 165 | "means", | |
| 166 | "mews", | |
| 167 | "mice", | |
| 168 | "mumps", | |
| 169 | "news", | |
| 170 | "pincers", | |
| 171 | "pliers", | |
| 172 | "Portuguese", | |
| 173 | "proceedings", | |
| 174 | "salmon", | |
| 175 | "scissors", | |
| 176 | "sea-bass", | |
| 177 | "Senegalese", | |
| 178 | "shears", | |
| 179 | "Siamese", | |
| 180 | "Sinhalese", | |
| 181 | "spacecraft", | |
| 182 | "swine", | |
| 183 | "trout", | |
| 184 | "tuna", | |
| 185 | "Vietnamese", | |
| 186 | "watercraft", | |
| 187 | "whiting", | |
| 188 | "wildebeest" | |
| 189 | ) | |
| 190 | ||
| 191 | is.element( s, v ) | |
| 192 | } | |
| 193 | ||
| 194 | # ----------------------------------------------------------------------------- | |
| 195 | # Answers whether the given string (s) is an irregular plural. | |
| 196 | # ----------------------------------------------------------------------------- | |
| 197 | pl.is.irregular.pl <- function( s ) { | |
| 198 | # Could be refactored with pl.irregular.pl... | |
| 199 | v <- c( | |
| 200 | "beef", "brother", "child", "cow", "ephemeris", "genie", "money", | |
| 201 | "mongoose", "mythos", "octopus", "ox", "soliloquy", "trilby" | |
| 202 | ) | |
| 203 | ||
| 204 | is.element( s, v ) | |
| 205 | } | |
| 206 | ||
| 207 | # ----------------------------------------------------------------------------- | |
| 208 | # Call to pluralize an irregular noun. Only call after confirming | |
| 209 | # the noun is irregular via pl.is.irregular.pl. | |
| 210 | # ----------------------------------------------------------------------------- | |
| 211 | pl.irregular.pl <- function( s ) { | |
| 212 | v <- list( | |
| 213 | "beef" = "beefs", | |
| 214 | "brother" = "brothers", | |
| 215 | "child" = "children", | |
| 216 | "cow" = "cows", | |
| 217 | "ephemeris" = "ephemerides", | |
| 218 | "genie" = "genies", | |
| 219 | "money" = "moneys", | |
| 220 | "mongoose" = "mongooses", | |
| 221 | "mythos" = "mythoi", | |
| 222 | "octopus" = "octopuses", | |
| 223 | "ox" = "oxen", | |
| 224 | "soliloquy" = "soliloquies", | |
| 225 | "trilby" = "trilbys" | |
| 226 | ) | |
| 227 | ||
| 228 | # Faster version of v[[ s ]] | |
| 229 | .subset2( v, s ) | |
| 230 | } | |
| 231 | ||
| 232 | # ----------------------------------------------------------------------------- | |
| 233 | # Answers whether the given string (s) pluralizes with -es. | |
| 234 | # ----------------------------------------------------------------------------- | |
| 235 | pl.is.irregular.es <- function( s ) { | |
| 236 | v <- c( | |
| 237 | "acropolis", "aegis", "alias", "asbestos", "bathos", "bias", "bronchitis", | |
| 238 | "bursitis", "caddis", "cannabis", "canvas", "chaos", "cosmos", "dais", | |
| 239 | "digitalis", "epidermis", "ethos", "eyas", "gas", "glottis", "hubris", | |
| 240 | "ibis", "lens", "mantis", "marquis", "metropolis", "pathos", "pelvis", | |
| 241 | "polis", "rhinoceros", "sassafrass", "trellis" | |
| 242 | ) | |
| 243 | ||
| 244 | is.element( s, v ) | |
| 245 | } | |
| 246 | ||
| 1 | 247 |
| 1 | # ###################################################################### | |
| 2 | # | |
| 3 | # Copyright 2017, White Magic Software, Ltd. | |
| 1 | # ----------------------------------------------------------------------------- | |
| 2 | # Copyright 2020, White Magic Software, Ltd. | |
| 4 | 3 | # |
| 5 | 4 | # Permission is hereby granted, free of charge, to any person obtaining |
| ... | ||
| 21 | 20 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
| 22 | 21 | # 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 | # ###################################################################### | |
| 22 | # ----------------------------------------------------------------------------- | |
| 31 | 23 | |
| 24 | # ----------------------------------------------------------------------------- | |
| 32 | 25 | # Returns leftmost n characters of s. |
| 26 | # ----------------------------------------------------------------------------- | |
| 33 | 27 | lstr <- function( s, n = 1 ) { |
| 34 | 28 | substr( s, 0, n ) |
| 35 | 29 | } |
| 36 | 30 | |
| 31 | # ----------------------------------------------------------------------------- | |
| 37 | 32 | # Returns rightmost n characters of s. |
| 33 | # ----------------------------------------------------------------------------- | |
| 38 | 34 | rstr <- function( s, n = 1 ) { |
| 39 | l = nchar( s ) | |
| 35 | l <- nchar( s ) | |
| 40 | 36 | substr( s, l - n + 1, l ) |
| 41 | 37 | } |
| 42 | 38 | |
| 43 | # Returns the possessive form of the given word. | |
| 39 | # ----------------------------------------------------------------------------- | |
| 40 | # Returns the possessive form of the given word, s. | |
| 41 | # ----------------------------------------------------------------------------- | |
| 44 | 42 | pos <- function( s ) { |
| 45 | result <- s | |
| 46 | ||
| 47 | # Check to see if the last character is an s. | |
| 48 | ch <- rstr( s, 1 ) | |
| 43 | lcs <- tolower( s ) | |
| 44 | pronouns <- c( 'your', 'our', 'her', 'it', 'their' ) | |
| 49 | 45 | |
| 50 | if( ch != "s" ) { | |
| 51 | result <- concat( result, "'s" ) | |
| 46 | if( lcs == 'my' ) { | |
| 47 | # Change "[Mm]y" to "[Mm]ine". | |
| 48 | s <- paste0( lstr( s, 1 ), "ine" ) | |
| 52 | 49 | } |
| 53 | else { | |
| 54 | result <- concat( result, "'" ) | |
| 50 | else if( lcs %in% pronouns ) { | |
| 51 | # Append an s to most pronouns. | |
| 52 | s <- paste0( s, 's' ) | |
| 53 | } | |
| 54 | else if( lcs != 'his' ) { | |
| 55 | # Possessive for all other words except 'his'. | |
| 56 | s <- paste0( s, ifelse( rstr( s, 1 ) == 's', "'" ,"'s" ) ) | |
| 55 | 57 | } |
| 56 | 58 | |
| 57 | result | |
| 59 | s | |
| 58 | 60 | } |
| 59 | 61 | |
| 3 | 3 | # $application.title$ |
| 4 | 4 | |
| 5 | Text editing using interpolated strings. | |
| 5 | A text editor that uses [interpolated strings](https://en.wikipedia.org/wiki/String_interpolation) to reference externally defined values. | |
| 6 | 6 | |
| 7 | 7 | ## Requirements |
| ... | ||
| 35 | 35 | * XML document transformation using XSLT3 or older |
| 36 | 36 | * Platform independent (Windows, Linux, MacOS) |
| 37 | ||
| 38 | ## Usage | |
| 39 | ||
| 40 | See the following documents for more information: | |
| 41 | ||
| 42 | * [USAGE.md](USAGE.md) - how variable definitions and string interpolation work. | |
| 43 | * [USAGE-R.md](USAGE-R.md) - how to call R functions in R Markdown documents. | |
| 37 | 44 | |
| 38 | 45 | ## Future Features |
| 152 | 152 | ``` r |
| 153 | 153 | x <- function( s ) { |
| 154 | tryCatch({ | |
| 154 | tryCatch( { | |
| 155 | 155 | r = eval( parse( text = s ) ) |
| 156 | 156 | |
| 157 | if( is.atomic( r ) ) { r } | |
| 158 | else { s } | |
| 157 | ifelse( is.atomic( r ), r, s ); | |
| 159 | 158 | }, |
| 160 | 159 | warning = function( w ) { s }, |
| 161 | error = function( e ) { s }) | |
| 160 | error = function( e ) { s } ) | |
| 162 | 161 | } |
| 163 | 162 | ``` |
| 38 | 38 | implementation 'de.jensd:fontawesomefx-commons:11.0' |
| 39 | 39 | implementation 'de.jensd:fontawesomefx-fontawesome:4.7.0-11' |
| 40 | implementation "org.renjin:renjin-script-engine:0.9.2726" | |
| 40 | implementation "org.renjin:renjin-script-engine:3.5-beta76" | |
| 41 | 41 | |
| 42 | 42 | def os = ['win', 'linux', 'mac'] |
| ... | ||
| 60 | 60 | |
| 61 | 61 | sourceCompatibility = JavaVersion.VERSION_11 |
| 62 | version = '1.4.0' | |
| 62 | version = '1.4.1' | |
| 63 | 63 | applicationName = 'scrivenvar' |
| 64 | 64 | mainClassName = 'com.scrivenvar.Main' |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | if( fileType == null ) { | |
| 97 | unknownFileType( fileType, path ); | |
| 98 | } | |
| 99 | ||
| 100 | 96 | return fileType; |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Throws IllegalArgumentException because the given path could not be | |
| 105 | * recognized. | |
| 106 | * | |
| 107 | * @param type The detected path type (protocol, file extension, etc.). | |
| 108 | * @param path The path to a source of definitions. | |
| 109 | */ | |
| 110 | protected void unknownFileType( final FileType type, final Path path ) { | |
| 111 | final String msg = format( MSG_UNKNOWN_FILE_TYPE, type, path ); | |
| 112 | throw new IllegalArgumentException( msg ); | |
| 113 | 97 | } |
| 114 | 98 |
| 38 | 38 | import javafx.beans.value.ChangeListener; |
| 39 | 39 | import javafx.beans.value.ObservableValue; |
| 40 | import javafx.event.Event; | |
| 41 | import javafx.scene.Node; | |
| 42 | import javafx.scene.Scene; | |
| 43 | import javafx.scene.control.Tab; | |
| 44 | import javafx.scene.control.Tooltip; | |
| 45 | import javafx.scene.input.InputEvent; | |
| 46 | import javafx.scene.text.Text; | |
| 47 | import javafx.stage.Window; | |
| 48 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 49 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 50 | import org.fxmisc.undo.UndoManager; | |
| 51 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 52 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 53 | import org.mozilla.universalchardet.UniversalDetector; | |
| 54 | ||
| 55 | import java.io.IOException; | |
| 56 | import java.nio.charset.Charset; | |
| 57 | import java.nio.file.Files; | |
| 58 | import java.nio.file.Path; | |
| 59 | import java.util.function.Consumer; | |
| 60 | ||
| 61 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 62 | import static java.util.Locale.ENGLISH; | |
| 63 | import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; | |
| 64 | ||
| 65 | /** | |
| 66 | * Editor for a single file. | |
| 67 | * | |
| 68 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 69 | */ | |
| 70 | public final class FileEditorTab extends Tab { | |
| 71 | ||
| 72 | /** | |
| 73 | * | |
| 74 | */ | |
| 75 | private final Notifier alertService = Services.load( Notifier.class ); | |
| 76 | private EditorPane editorPane; | |
| 77 | ||
| 78 | /** | |
| 79 | * Character encoding used by the file (or default encoding if none found). | |
| 80 | */ | |
| 81 | private Charset encoding; | |
| 82 | ||
| 83 | private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); | |
| 84 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 85 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 86 | ||
| 87 | private Path path; | |
| 88 | ||
| 89 | public FileEditorTab( final Path path ) { | |
| 90 | setPath( path ); | |
| 91 | ||
| 92 | this.modified.addListener( ( observable, oldPath, newPath ) -> updateTab() ); | |
| 93 | ||
| 94 | setOnSelectionChanged( e -> { | |
| 95 | if( isSelected() ) { | |
| 96 | Platform.runLater( this::activated ); | |
| 97 | } | |
| 98 | } ); | |
| 99 | } | |
| 100 | ||
| 101 | private void updateTab() { | |
| 102 | setText( getTabTitle() ); | |
| 103 | setGraphic( getModifiedMark() ); | |
| 104 | setTooltip( getTabTooltip() ); | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * Returns the base filename (without the directory names). | |
| 109 | * | |
| 110 | * @return The untitled text if the path hasn't been set. | |
| 111 | */ | |
| 112 | private String getTabTitle() { | |
| 113 | final Path filePath = getPath(); | |
| 114 | ||
| 115 | return (filePath == null) | |
| 116 | ? Messages.get( "FileEditor.untitled" ) | |
| 117 | : filePath.getFileName().toString(); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * Returns the full filename represented by the path. | |
| 122 | * | |
| 123 | * @return The untitled text if the path hasn't been set. | |
| 124 | */ | |
| 125 | private Tooltip getTabTooltip() { | |
| 126 | final Path filePath = getPath(); | |
| 127 | return new Tooltip( filePath == null ? "" : filePath.toString() ); | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Returns a marker to indicate whether the file has been modified. | |
| 132 | * | |
| 133 | * @return "*" when the file has changed; otherwise null. | |
| 134 | */ | |
| 135 | private Text getModifiedMark() { | |
| 136 | return isModified() ? new Text( "*" ) : null; | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Called when the user switches tab. | |
| 141 | */ | |
| 142 | private void activated() { | |
| 143 | // Tab is closed or no longer active. | |
| 144 | if( getTabPane() == null || !isSelected() ) { | |
| 145 | return; | |
| 146 | } | |
| 147 | ||
| 148 | // Switch to the tab without loading if the contents are already in memory. | |
| 149 | if( getContent() != null ) { | |
| 150 | getEditorPane().requestFocus(); | |
| 151 | return; | |
| 152 | } | |
| 153 | ||
| 154 | // Load the text and update the preview before the undo manager. | |
| 155 | load(); | |
| 156 | ||
| 157 | // Track undo requests -- can only be called *after* load. | |
| 158 | initUndoManager(); | |
| 159 | initLayout(); | |
| 160 | initFocus(); | |
| 161 | } | |
| 162 | ||
| 163 | private void initLayout() { | |
| 164 | setContent( getScrollPane() ); | |
| 165 | } | |
| 166 | ||
| 167 | private Node getScrollPane() { | |
| 168 | return getEditorPane().getScrollPane(); | |
| 169 | } | |
| 170 | ||
| 171 | private void initFocus() { | |
| 172 | getEditorPane().requestFocus(); | |
| 173 | } | |
| 174 | ||
| 175 | private void initUndoManager() { | |
| 176 | final UndoManager undoManager = getUndoManager(); | |
| 177 | ||
| 178 | // Clear undo history after first load. | |
| 179 | undoManager.forgetHistory(); | |
| 180 | ||
| 181 | // Bind the editor undo manager to the properties. | |
| 182 | modified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 183 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 184 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Searches from the caret position forward for the given string. | |
| 189 | * | |
| 190 | * @param needle The text string to match. | |
| 191 | */ | |
| 192 | public void searchNext( final String needle ) { | |
| 193 | final String haystack = getEditorText(); | |
| 194 | int index = haystack.indexOf( needle, getCaretPosition() ); | |
| 195 | ||
| 196 | // Wrap around. | |
| 197 | if( index == -1 ) { | |
| 198 | index = haystack.indexOf( needle, 0 ); | |
| 199 | } | |
| 200 | ||
| 201 | if( index >= 0 ) { | |
| 202 | setCaretPosition( index ); | |
| 203 | getEditor().selectRange( index, index + needle.length() ); | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Returns the index into the text where the caret blinks happily away. | |
| 209 | * | |
| 210 | * @return A number from 0 to the editor's document text length. | |
| 211 | */ | |
| 212 | public int getCaretPosition() { | |
| 213 | return getEditor().getCaretPosition(); | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Moves the caret to a given offset. | |
| 218 | * | |
| 219 | * @param offset The new caret offset. | |
| 220 | */ | |
| 221 | private void setCaretPosition( final int offset ) { | |
| 222 | getEditor().moveTo( offset ); | |
| 223 | getEditor().requestFollowCaret(); | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * Returns the caret's current row and column position. | |
| 228 | * | |
| 229 | * @return The caret's offset into the document. | |
| 230 | */ | |
| 231 | public Position getCaretOffset() { | |
| 232 | return getEditor().offsetToPosition( getCaretPosition(), Forward ); | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Allows observers to synchronize caret position changes. | |
| 237 | * | |
| 238 | * @return An observable caret property value. | |
| 239 | */ | |
| 240 | public final ObservableValue<Integer> caretPositionProperty() { | |
| 241 | return getEditor().caretPositionProperty(); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Returns the text area associated with this tab. | |
| 246 | * | |
| 247 | * @return A text editor. | |
| 248 | */ | |
| 249 | private StyleClassedTextArea getEditor() { | |
| 250 | return getEditorPane().getEditor(); | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * Returns true if the given path exactly matches this tab's path. | |
| 255 | * | |
| 256 | * @param check The path to compare against. | |
| 257 | * @return true The paths are the same. | |
| 258 | */ | |
| 259 | public boolean isPath( final Path check ) { | |
| 260 | final Path filePath = getPath(); | |
| 261 | ||
| 262 | return filePath != null && filePath.equals( check ); | |
| 263 | } | |
| 264 | ||
| 265 | /** | |
| 266 | * Reads the entire file contents from the path associated with this tab. | |
| 267 | */ | |
| 268 | private void load() { | |
| 269 | final Path filePath = getPath(); | |
| 270 | ||
| 271 | if( filePath != null ) { | |
| 272 | try { | |
| 273 | getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) ); | |
| 274 | getEditorPane().scrollToTop(); | |
| 275 | } catch( final Exception ex ) { | |
| 276 | getNotifyService().notify( ex ); | |
| 277 | } | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Saves the entire file contents from the path associated with this tab. | |
| 283 | * | |
| 284 | * @return true The file has been saved. | |
| 285 | */ | |
| 286 | public boolean save() { | |
| 287 | try { | |
| 288 | final EditorPane editor = getEditorPane(); | |
| 289 | Files.write( getPath(), asBytes( editor.getText() ) ); | |
| 290 | editor.getUndoManager().mark(); | |
| 291 | return true; | |
| 292 | } catch( final IOException ex ) { | |
| 293 | return alert( | |
| 294 | "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex | |
| 295 | ); | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Creates an alert dialog and waits for it to close. | |
| 301 | * | |
| 302 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 303 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 304 | * @param e The unexpected happening. | |
| 305 | * @return false | |
| 306 | */ | |
| 307 | private boolean alert( | |
| 308 | final String titleKey, final String messageKey, final Exception e ) { | |
| 309 | final Notifier service = getNotifyService(); | |
| 310 | final Path filePath = getPath(); | |
| 311 | ||
| 312 | final Notification message = service.createNotification( | |
| 313 | Messages.get( titleKey ), | |
| 314 | Messages.get( messageKey ), | |
| 315 | filePath == null ? "" : filePath, | |
| 316 | e.getMessage() | |
| 317 | ); | |
| 318 | ||
| 319 | try { | |
| 320 | service.createError( getWindow(), message ).showAndWait(); | |
| 321 | } catch( final Exception ex ) { | |
| 322 | getNotifyService().notify( ex ); | |
| 323 | } | |
| 324 | ||
| 325 | return false; | |
| 326 | } | |
| 327 | ||
| 328 | private Window getWindow() { | |
| 329 | final Scene scene = getEditorPane().getScene(); | |
| 330 | ||
| 331 | if( scene == null ) { | |
| 332 | throw new UnsupportedOperationException( "" ); | |
| 333 | } | |
| 334 | ||
| 335 | return scene.getWindow(); | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 340 | * detected, this will return the default charset for the JVM. | |
| 341 | * | |
| 342 | * @param bytes The bytes to perform character encoding detection. | |
| 343 | * @return The character encoding. | |
| 344 | */ | |
| 345 | private Charset detectEncoding( final byte[] bytes ) { | |
| 346 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 347 | detector.handleData( bytes, 0, bytes.length ); | |
| 348 | detector.dataEnd(); | |
| 349 | ||
| 350 | final String charset = detector.getDetectedCharset(); | |
| 351 | final Charset charEncoding = charset == null | |
| 352 | ? Charset.defaultCharset() | |
| 353 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 354 | ||
| 355 | detector.reset(); | |
| 356 | ||
| 357 | return charEncoding; | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Converts the given string to an array of bytes using the encoding that was | |
| 362 | * originally detected (if any) and associated with this file. | |
| 363 | * | |
| 364 | * @param text The text to convert into the original file encoding. | |
| 365 | * @return A series of bytes ready for writing to a file. | |
| 366 | */ | |
| 367 | private byte[] asBytes( final String text ) { | |
| 368 | return text.getBytes( getEncoding() ); | |
| 369 | } | |
| 370 | ||
| 371 | /** | |
| 372 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 373 | * with the encoding detected by the CharsetDetector. | |
| 374 | * | |
| 375 | * @param text The text of unknown character encoding. | |
| 376 | * @return The text, in its auto-detected encoding, as a String. | |
| 377 | */ | |
| 378 | private String asString( final byte[] text ) { | |
| 379 | setEncoding( detectEncoding( text ) ); | |
| 380 | return new String( text, getEncoding() ); | |
| 381 | } | |
| 382 | ||
| 383 | public Path getPath() { | |
| 384 | return this.path; | |
| 385 | } | |
| 386 | ||
| 387 | public void setPath( final Path path ) { | |
| 388 | this.path = path; | |
| 389 | ||
| 390 | updateTab(); | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Answers whether this tab has an initialized path reference. | |
| 395 | * | |
| 396 | * @return false This tab has no path. | |
| 397 | */ | |
| 398 | public boolean isFileOpen() { | |
| 399 | return this.path != null; | |
| 400 | } | |
| 401 | ||
| 402 | public boolean isModified() { | |
| 403 | return this.modified.get(); | |
| 404 | } | |
| 405 | ||
| 406 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 407 | return this.modified.getReadOnlyProperty(); | |
| 408 | } | |
| 409 | ||
| 410 | BooleanProperty canUndoProperty() { | |
| 411 | return this.canUndo; | |
| 412 | } | |
| 413 | ||
| 414 | BooleanProperty canRedoProperty() { | |
| 415 | return this.canRedo; | |
| 416 | } | |
| 417 | ||
| 418 | private UndoManager getUndoManager() { | |
| 419 | return getEditorPane().getUndoManager(); | |
| 420 | } | |
| 421 | ||
| 422 | /** | |
| 423 | * Forwards the request to the editor pane. | |
| 424 | * | |
| 425 | * @param <T> The type of event listener to add. | |
| 426 | * @param <U> The type of consumer to add. | |
| 427 | * @param event The event that should trigger updates to the listener. | |
| 428 | * @param consumer The listener to receive update events. | |
| 429 | */ | |
| 430 | public <T extends Event, U extends T> void addEventListener( | |
| 431 | final EventPattern<? super T, ? extends U> event, | |
| 432 | final Consumer<? super U> consumer ) { | |
| 433 | getEditorPane().addKeyboardListener( event, consumer ); | |
| 434 | } | |
| 435 | ||
| 436 | /** | |
| 437 | * Forwards to the editor pane's listeners for keyboard events. | |
| 438 | * | |
| 439 | * @param map The new input map to replace the existing keyboard listener. | |
| 440 | */ | |
| 441 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 442 | getEditorPane().addEventListener( map ); | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Forwards to the editor pane's listeners for keyboard events. | |
| 447 | * | |
| 448 | * @param map The existing input map to remove from the keyboard listeners. | |
| 449 | */ | |
| 450 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 451 | getEditorPane().removeEventListener( map ); | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Forwards to the editor pane's listeners for text change events. | |
| 456 | * | |
| 457 | * @param listener The listener to notify when the text changes. | |
| 458 | */ | |
| 459 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 460 | getEditorPane().addTextChangeListener( listener ); | |
| 461 | } | |
| 462 | ||
| 463 | /** | |
| 464 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 465 | * | |
| 466 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 467 | */ | |
| 468 | public void addCaretParagraphListener( | |
| 469 | final ChangeListener<Integer> listener ) { | |
| 470 | getEditorPane().addCaretParagraphListener( listener ); | |
| 471 | } | |
| 472 | ||
| 473 | /** | |
| 474 | * Forwards the request to the editor pane. | |
| 475 | * | |
| 476 | * @return The text to process. | |
| 477 | */ | |
| 478 | public String getEditorText() { | |
| 479 | return getEditorPane().getText(); | |
| 480 | } | |
| 481 | ||
| 482 | /** | |
| 483 | * Returns the editor pane, or creates one if it doesn't yet exist. | |
| 484 | * | |
| 485 | * @return The editor pane, never null. | |
| 486 | */ | |
| 487 | public synchronized EditorPane getEditorPane() { | |
| 488 | if( this.editorPane == null ) { | |
| 489 | this.editorPane = new MarkdownEditorPane(); | |
| 490 | } | |
| 491 | ||
| 492 | return this.editorPane; | |
| 493 | } | |
| 494 | ||
| 495 | private Notifier getNotifyService() { | |
| 496 | return this.alertService; | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been | |
| 501 | * determined. | |
| 502 | * | |
| 503 | * @return The file encoding or UTF-8 if unknown. | |
| 504 | */ | |
| 505 | private Charset getEncoding() { | |
| 506 | if( this.encoding == null ) { | |
| 507 | this.encoding = UTF_8; | |
| 508 | } | |
| 509 | ||
| 510 | return this.encoding; | |
| 511 | } | |
| 512 | ||
| 513 | private void setEncoding( final Charset encoding ) { | |
| 514 | this.encoding = encoding; | |
| 40 | import javafx.scene.Node; | |
| 41 | import javafx.scene.Scene; | |
| 42 | import javafx.scene.control.Tab; | |
| 43 | import javafx.scene.control.Tooltip; | |
| 44 | import javafx.scene.text.Text; | |
| 45 | import javafx.stage.Window; | |
| 46 | import org.fxmisc.richtext.StyleClassedTextArea; | |
| 47 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 48 | import org.fxmisc.undo.UndoManager; | |
| 49 | import org.mozilla.universalchardet.UniversalDetector; | |
| 50 | ||
| 51 | import java.io.IOException; | |
| 52 | import java.nio.charset.Charset; | |
| 53 | import java.nio.file.Files; | |
| 54 | import java.nio.file.Path; | |
| 55 | ||
| 56 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 57 | import static java.util.Locale.ENGLISH; | |
| 58 | import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward; | |
| 59 | ||
| 60 | /** | |
| 61 | * Editor for a single file. | |
| 62 | * | |
| 63 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 64 | */ | |
| 65 | public final class FileEditorTab extends Tab { | |
| 66 | ||
| 67 | private final Notifier mAlertService = Services.load( Notifier.class ); | |
| 68 | private final EditorPane mEditorPane = new MarkdownEditorPane(); | |
| 69 | ||
| 70 | private final ReadOnlyBooleanWrapper mModified = new ReadOnlyBooleanWrapper(); | |
| 71 | private final BooleanProperty canUndo = new SimpleBooleanProperty(); | |
| 72 | private final BooleanProperty canRedo = new SimpleBooleanProperty(); | |
| 73 | ||
| 74 | /** | |
| 75 | * Character encoding used by the file (or default encoding if none found). | |
| 76 | */ | |
| 77 | private Charset mEncoding = UTF_8; | |
| 78 | ||
| 79 | /** | |
| 80 | * File to load into the editor. | |
| 81 | */ | |
| 82 | private Path mPath; | |
| 83 | ||
| 84 | public FileEditorTab( final Path path ) { | |
| 85 | setPath( path ); | |
| 86 | ||
| 87 | mModified.addListener( ( observable, oldPath, newPath ) -> updateTab() ); | |
| 88 | ||
| 89 | setOnSelectionChanged( e -> { | |
| 90 | if( isSelected() ) { | |
| 91 | Platform.runLater( this::activated ); | |
| 92 | } | |
| 93 | } ); | |
| 94 | } | |
| 95 | ||
| 96 | private void updateTab() { | |
| 97 | setText( getTabTitle() ); | |
| 98 | setGraphic( getModifiedMark() ); | |
| 99 | setTooltip( getTabTooltip() ); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Returns the base filename (without the directory names). | |
| 104 | * | |
| 105 | * @return The untitled text if the path hasn't been set. | |
| 106 | */ | |
| 107 | private String getTabTitle() { | |
| 108 | final Path filePath = getPath(); | |
| 109 | ||
| 110 | return (filePath == null) | |
| 111 | ? Messages.get( "FileEditor.untitled" ) | |
| 112 | : filePath.getFileName().toString(); | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * Returns the full filename represented by the path. | |
| 117 | * | |
| 118 | * @return The untitled text if the path hasn't been set. | |
| 119 | */ | |
| 120 | private Tooltip getTabTooltip() { | |
| 121 | final Path filePath = getPath(); | |
| 122 | return new Tooltip( filePath == null ? "" : filePath.toString() ); | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Returns a marker to indicate whether the file has been modified. | |
| 127 | * | |
| 128 | * @return "*" when the file has changed; otherwise null. | |
| 129 | */ | |
| 130 | private Text getModifiedMark() { | |
| 131 | return isModified() ? new Text( "*" ) : null; | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Called when the user switches tab. | |
| 136 | */ | |
| 137 | private void activated() { | |
| 138 | // Tab is closed or no longer active. | |
| 139 | if( getTabPane() == null || !isSelected() ) { | |
| 140 | return; | |
| 141 | } | |
| 142 | ||
| 143 | // Switch to the tab without loading if the contents are already in memory. | |
| 144 | if( getContent() != null ) { | |
| 145 | getEditorPane().requestFocus(); | |
| 146 | return; | |
| 147 | } | |
| 148 | ||
| 149 | // Load the text and update the preview before the undo manager. | |
| 150 | load(); | |
| 151 | ||
| 152 | // Track undo requests -- can only be called *after* load. | |
| 153 | initUndoManager(); | |
| 154 | initLayout(); | |
| 155 | initFocus(); | |
| 156 | } | |
| 157 | ||
| 158 | private void initLayout() { | |
| 159 | setContent( getScrollPane() ); | |
| 160 | } | |
| 161 | ||
| 162 | private Node getScrollPane() { | |
| 163 | return getEditorPane().getScrollPane(); | |
| 164 | } | |
| 165 | ||
| 166 | private void initFocus() { | |
| 167 | getEditorPane().requestFocus(); | |
| 168 | } | |
| 169 | ||
| 170 | private void initUndoManager() { | |
| 171 | final UndoManager<?> undoManager = getUndoManager(); | |
| 172 | undoManager.forgetHistory(); | |
| 173 | ||
| 174 | // Bind the editor undo manager to the properties. | |
| 175 | mModified.bind( Bindings.not( undoManager.atMarkedPositionProperty() ) ); | |
| 176 | canUndo.bind( undoManager.undoAvailableProperty() ); | |
| 177 | canRedo.bind( undoManager.redoAvailableProperty() ); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Searches from the caret position forward for the given string. | |
| 182 | * | |
| 183 | * @param needle The text string to match. | |
| 184 | */ | |
| 185 | public void searchNext( final String needle ) { | |
| 186 | final String haystack = getEditorText(); | |
| 187 | int index = haystack.indexOf( needle, getCaretPosition() ); | |
| 188 | ||
| 189 | // Wrap around. | |
| 190 | if( index == -1 ) { | |
| 191 | index = haystack.indexOf( needle ); | |
| 192 | } | |
| 193 | ||
| 194 | if( index >= 0 ) { | |
| 195 | setCaretPosition( index ); | |
| 196 | getEditor().selectRange( index, index + needle.length() ); | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Returns the index into the text where the caret blinks happily away. | |
| 202 | * | |
| 203 | * @return A number from 0 to the editor's document text length. | |
| 204 | */ | |
| 205 | public int getCaretPosition() { | |
| 206 | return getEditor().getCaretPosition(); | |
| 207 | } | |
| 208 | ||
| 209 | /** | |
| 210 | * Moves the caret to a given offset. | |
| 211 | * | |
| 212 | * @param offset The new caret offset. | |
| 213 | */ | |
| 214 | private void setCaretPosition( final int offset ) { | |
| 215 | getEditor().moveTo( offset ); | |
| 216 | getEditor().requestFollowCaret(); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Returns the caret's current row and column position. | |
| 221 | * | |
| 222 | * @return The caret's offset into the document. | |
| 223 | */ | |
| 224 | public Position getCaretOffset() { | |
| 225 | return getEditor().offsetToPosition( getCaretPosition(), Forward ); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Allows observers to synchronize caret position changes. | |
| 230 | * | |
| 231 | * @return An observable caret property value. | |
| 232 | */ | |
| 233 | public final ObservableValue<Integer> caretPositionProperty() { | |
| 234 | return getEditor().caretPositionProperty(); | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Returns the text area associated with this tab. | |
| 239 | * | |
| 240 | * @return A text editor. | |
| 241 | */ | |
| 242 | private StyleClassedTextArea getEditor() { | |
| 243 | return getEditorPane().getEditor(); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Returns true if the given path exactly matches this tab's path. | |
| 248 | * | |
| 249 | * @param check The path to compare against. | |
| 250 | * @return true The paths are the same. | |
| 251 | */ | |
| 252 | public boolean isPath( final Path check ) { | |
| 253 | final Path filePath = getPath(); | |
| 254 | ||
| 255 | return filePath != null && filePath.equals( check ); | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Reads the entire file contents from the path associated with this tab. | |
| 260 | */ | |
| 261 | private void load() { | |
| 262 | final Path filePath = getPath(); | |
| 263 | ||
| 264 | if( filePath != null ) { | |
| 265 | try { | |
| 266 | getEditorPane().setText( asString( Files.readAllBytes( filePath ) ) ); | |
| 267 | getEditorPane().scrollToTop(); | |
| 268 | } catch( final Exception ex ) { | |
| 269 | getNotifyService().notify( ex ); | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Saves the entire file contents from the path associated with this tab. | |
| 276 | * | |
| 277 | * @return true The file has been saved. | |
| 278 | */ | |
| 279 | public boolean save() { | |
| 280 | try { | |
| 281 | final EditorPane editor = getEditorPane(); | |
| 282 | Files.write( getPath(), asBytes( editor.getText() ) ); | |
| 283 | editor.getUndoManager().mark(); | |
| 284 | return true; | |
| 285 | } catch( final IOException ex ) { | |
| 286 | return alert( | |
| 287 | "FileEditor.saveFailed.title", "FileEditor.saveFailed.message", ex | |
| 288 | ); | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates an alert dialog and waits for it to close. | |
| 294 | * | |
| 295 | * @param titleKey Resource bundle key for the alert dialog title. | |
| 296 | * @param messageKey Resource bundle key for the alert dialog message. | |
| 297 | * @param e The unexpected happening. | |
| 298 | * @return false | |
| 299 | */ | |
| 300 | @SuppressWarnings("SameParameterValue") | |
| 301 | private boolean alert( | |
| 302 | final String titleKey, final String messageKey, final Exception e ) { | |
| 303 | final Notifier service = getNotifyService(); | |
| 304 | final Path filePath = getPath(); | |
| 305 | ||
| 306 | final Notification message = service.createNotification( | |
| 307 | Messages.get( titleKey ), | |
| 308 | Messages.get( messageKey ), | |
| 309 | filePath == null ? "" : filePath, | |
| 310 | e.getMessage() | |
| 311 | ); | |
| 312 | ||
| 313 | try { | |
| 314 | service.createError( getWindow(), message ).showAndWait(); | |
| 315 | } catch( final Exception ex ) { | |
| 316 | getNotifyService().notify( ex ); | |
| 317 | } | |
| 318 | ||
| 319 | return false; | |
| 320 | } | |
| 321 | ||
| 322 | private Window getWindow() { | |
| 323 | final Scene scene = getEditorPane().getScene(); | |
| 324 | ||
| 325 | if( scene == null ) { | |
| 326 | throw new UnsupportedOperationException( "No scene window available" ); | |
| 327 | } | |
| 328 | ||
| 329 | return scene.getWindow(); | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Returns a best guess at the file encoding. If the encoding could not be | |
| 334 | * detected, this will return the default charset for the JVM. | |
| 335 | * | |
| 336 | * @param bytes The bytes to perform character encoding detection. | |
| 337 | * @return The character encoding. | |
| 338 | */ | |
| 339 | private Charset detectEncoding( final byte[] bytes ) { | |
| 340 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 341 | detector.handleData( bytes, 0, bytes.length ); | |
| 342 | detector.dataEnd(); | |
| 343 | ||
| 344 | final String charset = detector.getDetectedCharset(); | |
| 345 | final Charset charEncoding = charset == null | |
| 346 | ? Charset.defaultCharset() | |
| 347 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); | |
| 348 | ||
| 349 | detector.reset(); | |
| 350 | ||
| 351 | return charEncoding; | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Converts the given string to an array of bytes using the encoding that was | |
| 356 | * originally detected (if any) and associated with this file. | |
| 357 | * | |
| 358 | * @param text The text to convert into the original file encoding. | |
| 359 | * @return A series of bytes ready for writing to a file. | |
| 360 | */ | |
| 361 | private byte[] asBytes( final String text ) { | |
| 362 | return text.getBytes( getEncoding() ); | |
| 363 | } | |
| 364 | ||
| 365 | /** | |
| 366 | * Converts the given bytes into a Java String. This will call setEncoding | |
| 367 | * with the encoding detected by the CharsetDetector. | |
| 368 | * | |
| 369 | * @param text The text of unknown character encoding. | |
| 370 | * @return The text, in its auto-detected encoding, as a String. | |
| 371 | */ | |
| 372 | private String asString( final byte[] text ) { | |
| 373 | setEncoding( detectEncoding( text ) ); | |
| 374 | return new String( text, getEncoding() ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Returns the path to the file being edited in this tab. | |
| 379 | * | |
| 380 | * @return A non-null instance. | |
| 381 | */ | |
| 382 | public Path getPath() { | |
| 383 | return mPath; | |
| 384 | } | |
| 385 | ||
| 386 | /** | |
| 387 | * Sets the path to a file for editing and then updates the tab with the | |
| 388 | * file contents. | |
| 389 | * | |
| 390 | * @param path A non-null instance. | |
| 391 | */ | |
| 392 | public void setPath( final Path path ) { | |
| 393 | assert path != null; | |
| 394 | ||
| 395 | mPath = path; | |
| 396 | ||
| 397 | updateTab(); | |
| 398 | } | |
| 399 | ||
| 400 | public boolean isModified() { | |
| 401 | return mModified.get(); | |
| 402 | } | |
| 403 | ||
| 404 | ReadOnlyBooleanProperty modifiedProperty() { | |
| 405 | return mModified.getReadOnlyProperty(); | |
| 406 | } | |
| 407 | ||
| 408 | BooleanProperty canUndoProperty() { | |
| 409 | return this.canUndo; | |
| 410 | } | |
| 411 | ||
| 412 | BooleanProperty canRedoProperty() { | |
| 413 | return this.canRedo; | |
| 414 | } | |
| 415 | ||
| 416 | private UndoManager<?> getUndoManager() { | |
| 417 | return getEditorPane().getUndoManager(); | |
| 418 | } | |
| 419 | ||
| 420 | /** | |
| 421 | * Forwards to the editor pane's listeners for text change events. | |
| 422 | * | |
| 423 | * @param listener The listener to notify when the text changes. | |
| 424 | */ | |
| 425 | public void addTextChangeListener( final ChangeListener<String> listener ) { | |
| 426 | getEditorPane().addTextChangeListener( listener ); | |
| 427 | } | |
| 428 | ||
| 429 | /** | |
| 430 | * Forwards to the editor pane's listeners for caret paragraph change events. | |
| 431 | * | |
| 432 | * @param listener The listener to notify when the caret changes paragraphs. | |
| 433 | */ | |
| 434 | public void addCaretParagraphListener( | |
| 435 | final ChangeListener<Integer> listener ) { | |
| 436 | getEditorPane().addCaretParagraphListener( listener ); | |
| 437 | } | |
| 438 | ||
| 439 | /** | |
| 440 | * Forwards the request to the editor pane. | |
| 441 | * | |
| 442 | * @return The text to process. | |
| 443 | */ | |
| 444 | public String getEditorText() { | |
| 445 | return getEditorPane().getText(); | |
| 446 | } | |
| 447 | ||
| 448 | /** | |
| 449 | * Returns the editor pane, or creates one if it doesn't yet exist. | |
| 450 | * | |
| 451 | * @return The editor pane, never null. | |
| 452 | */ | |
| 453 | public EditorPane getEditorPane() { | |
| 454 | return mEditorPane; | |
| 455 | } | |
| 456 | ||
| 457 | private Notifier getNotifyService() { | |
| 458 | return mAlertService; | |
| 459 | } | |
| 460 | ||
| 461 | /** | |
| 462 | * Returns the encoding for the file, defaulting to UTF-8 if it hasn't been | |
| 463 | * determined. | |
| 464 | * | |
| 465 | * @return The file encoding or UTF-8 if unknown. | |
| 466 | */ | |
| 467 | private Charset getEncoding() { | |
| 468 | return mEncoding; | |
| 469 | } | |
| 470 | ||
| 471 | private void setEncoding( final Charset encoding ) { | |
| 472 | assert encoding != null; | |
| 473 | ||
| 474 | mEncoding = encoding; | |
| 515 | 475 | } |
| 516 | 476 |
| 48 | 48 | import javafx.scene.control.Tab; |
| 49 | 49 | import javafx.scene.control.TabPane; |
| 50 | import javafx.scene.input.InputEvent; | |
| 51 | import javafx.stage.FileChooser; | |
| 52 | import javafx.stage.FileChooser.ExtensionFilter; | |
| 53 | import javafx.stage.Window; | |
| 54 | import org.fxmisc.richtext.StyledTextArea; | |
| 55 | import org.fxmisc.wellbehaved.event.EventPattern; | |
| 56 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 57 | ||
| 58 | import java.io.File; | |
| 59 | import java.nio.file.Path; | |
| 60 | import java.util.ArrayList; | |
| 61 | import java.util.List; | |
| 62 | import java.util.function.Consumer; | |
| 63 | import java.util.prefs.Preferences; | |
| 64 | import java.util.stream.Collectors; | |
| 65 | ||
| 66 | import static com.scrivenvar.Constants.GLOB_PREFIX_FILE; | |
| 67 | import static com.scrivenvar.FileType.*; | |
| 68 | import static com.scrivenvar.Messages.get; | |
| 69 | import static com.scrivenvar.service.events.Notifier.NO; | |
| 70 | import static com.scrivenvar.service.events.Notifier.YES; | |
| 71 | ||
| 72 | /** | |
| 73 | * Tab pane for file editors. | |
| 74 | * | |
| 75 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 76 | */ | |
| 77 | public final class FileEditorTabPane extends TabPane { | |
| 78 | ||
| 79 | private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose" + | |
| 80 | ".filter"; | |
| 81 | ||
| 82 | private final Options options = Services.load( Options.class ); | |
| 83 | private final Settings settings = Services.load( Settings.class ); | |
| 84 | private final Notifier notifyService = Services.load( Notifier.class ); | |
| 85 | ||
| 86 | private final ReadOnlyObjectWrapper<Path> openDefinition = | |
| 87 | new ReadOnlyObjectWrapper<>(); | |
| 88 | private final ReadOnlyObjectWrapper<FileEditorTab> activeFileEditor = | |
| 89 | new ReadOnlyObjectWrapper<>(); | |
| 90 | private final ReadOnlyBooleanWrapper anyFileEditorModified = | |
| 91 | new ReadOnlyBooleanWrapper(); | |
| 92 | ||
| 93 | /** | |
| 94 | * Constructs a new file editor tab pane. | |
| 95 | */ | |
| 96 | public FileEditorTabPane() { | |
| 97 | final ObservableList<Tab> tabs = getTabs(); | |
| 98 | ||
| 99 | setFocusTraversable( false ); | |
| 100 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 101 | ||
| 102 | addTabSelectionListener( | |
| 103 | ( ObservableValue<? extends Tab> tabPane, | |
| 104 | final Tab oldTab, final Tab newTab ) -> { | |
| 105 | ||
| 106 | if( newTab != null ) { | |
| 107 | activeFileEditor.set( (FileEditorTab) newTab ); | |
| 108 | } | |
| 109 | } | |
| 110 | ); | |
| 111 | ||
| 112 | final ChangeListener<Boolean> modifiedListener = ( observable, oldValue, | |
| 113 | newValue ) -> { | |
| 114 | for( final Tab tab : tabs ) { | |
| 115 | if( ((FileEditorTab) tab).isModified() ) { | |
| 116 | this.anyFileEditorModified.set( true ); | |
| 117 | break; | |
| 118 | } | |
| 119 | } | |
| 120 | }; | |
| 121 | ||
| 122 | tabs.addListener( | |
| 123 | (ListChangeListener<Tab>) change -> { | |
| 124 | while( change.next() ) { | |
| 125 | if( change.wasAdded() ) { | |
| 126 | change.getAddedSubList().forEach( | |
| 127 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 128 | .addListener( modifiedListener ) ); | |
| 129 | } | |
| 130 | else if( change.wasRemoved() ) { | |
| 131 | change.getRemoved().forEach( | |
| 132 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 133 | .removeListener( | |
| 134 | modifiedListener ) ); | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | // Changes in the tabs may also change anyFileEditorModified property | |
| 139 | // (e.g. closed modified file) | |
| 140 | modifiedListener.changed( null, null, null ); | |
| 141 | } | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Delegates to the active file editor. | |
| 147 | * | |
| 148 | * @param <T> Event type. | |
| 149 | * @param <U> Consumer type. | |
| 150 | * @param event Event to pass to the editor. | |
| 151 | * @param consumer Consumer to pass to the editor. | |
| 152 | */ | |
| 153 | public <T extends Event, U extends T> void addEventListener( | |
| 154 | final EventPattern<? super T, ? extends U> event, | |
| 155 | final Consumer<? super U> consumer ) { | |
| 156 | getActiveFileEditor().addEventListener( event, consumer ); | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Delegates to the active file editor pane, and, ultimately, to its text | |
| 161 | * area. | |
| 162 | * | |
| 163 | * @param map The map of methods to events. | |
| 164 | */ | |
| 165 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 166 | getActiveFileEditor().addEventListener( map ); | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Remove a keyboard event listener from the active file editor. | |
| 171 | * | |
| 172 | * @param map The keyboard events to remove. | |
| 173 | */ | |
| 174 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 175 | getActiveFileEditor().removeEventListener( map ); | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * Allows observers to be notified when the current file editor tab changes. | |
| 180 | * | |
| 181 | * @param listener The listener to notify of tab change events. | |
| 182 | */ | |
| 183 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 184 | // Observe the tab so that when a new tab is opened or selected, | |
| 185 | // a notification is kicked off. | |
| 186 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Allows clients to manipulate the editor content directly. | |
| 191 | * | |
| 192 | * @return The text area for the active file editor. | |
| 193 | */ | |
| 194 | public StyledTextArea getEditor() { | |
| 195 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Returns the tab that has keyboard focus. | |
| 200 | * | |
| 201 | * @return A non-null instance. | |
| 202 | */ | |
| 203 | public FileEditorTab getActiveFileEditor() { | |
| 204 | return this.activeFileEditor.get(); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Returns the property corresponding to the tab that has focus. | |
| 209 | * | |
| 210 | * @return A non-null instance. | |
| 211 | */ | |
| 212 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 213 | return this.activeFileEditor.getReadOnlyProperty(); | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Property that can answer whether the text has been modified. | |
| 218 | * | |
| 219 | * @return A non-null instance, true meaning the content has not been saved. | |
| 220 | */ | |
| 221 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 222 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Creates a new editor instance from the given path. | |
| 227 | * | |
| 228 | * @param path The file to open. | |
| 229 | * @return A non-null instance. | |
| 230 | */ | |
| 231 | private FileEditorTab createFileEditor( final Path path ) { | |
| 232 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 233 | ||
| 234 | tab.setOnCloseRequest( e -> { | |
| 235 | if( !canCloseEditor( tab ) ) { | |
| 236 | e.consume(); | |
| 237 | } | |
| 238 | } ); | |
| 239 | ||
| 240 | return tab; | |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * Called when the user selects New from the File menu. | |
| 245 | */ | |
| 246 | void newEditor() { | |
| 247 | final FileEditorTab tab = createFileEditor( null ); | |
| 248 | ||
| 249 | getTabs().add( tab ); | |
| 250 | getSelectionModel().select( tab ); | |
| 251 | } | |
| 252 | ||
| 253 | void openFileDialog() { | |
| 254 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 255 | final FileChooser dialog = createFileChooser( title ); | |
| 256 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 257 | ||
| 258 | if( files != null ) { | |
| 259 | openFiles( files ); | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Opens the files into new editors, unless one of those files was a | |
| 265 | * definition file. The definition file is loaded into the definition pane, | |
| 266 | * but only the first one selected (multiple definition files will result in a | |
| 267 | * warning). | |
| 268 | * | |
| 269 | * @param files The list of non-definition files that the were requested to | |
| 270 | * open. | |
| 271 | */ | |
| 272 | private void openFiles( final List<File> files ) { | |
| 273 | final FileTypePredicate predicate | |
| 274 | = | |
| 275 | new FileTypePredicate( createExtensionFilter( DEFINITION ).getExtensions() ); | |
| 276 | ||
| 277 | // The user might have opened multiple definitions files. These will | |
| 278 | // be discarded from the text editable files. | |
| 279 | final List<File> definitions | |
| 280 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 281 | ||
| 282 | // Create a modifiable list to remove any definition files that were | |
| 283 | // opened. | |
| 284 | final List<File> editors = new ArrayList<>( files ); | |
| 285 | ||
| 286 | if( editors.size() > 0 ) { | |
| 287 | saveLastDirectory( editors.get( 0 ) ); | |
| 288 | } | |
| 289 | ||
| 290 | editors.removeAll( definitions ); | |
| 291 | ||
| 292 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 293 | if( editors.size() > 0 ) { | |
| 294 | openEditors( editors, 0 ); | |
| 295 | } | |
| 296 | ||
| 297 | if( definitions.size() > 0 ) { | |
| 298 | openDefinition( definitions.get( 0 ) ); | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 303 | final int fileTally = files.size(); | |
| 304 | final List<Tab> tabs = getTabs(); | |
| 305 | ||
| 306 | // Close single unmodified "Untitled" tab. | |
| 307 | if( tabs.size() == 1 ) { | |
| 308 | final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 )); | |
| 309 | ||
| 310 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 311 | closeEditor( fileEditor, false ); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | for( int i = 0; i < fileTally; i++ ) { | |
| 316 | final Path path = files.get( i ).toPath(); | |
| 317 | ||
| 318 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 319 | ||
| 320 | // Only open new files. | |
| 321 | if( fileEditorTab == null ) { | |
| 322 | fileEditorTab = createFileEditor( path ); | |
| 323 | getTabs().add( fileEditorTab ); | |
| 324 | } | |
| 325 | ||
| 326 | // Select the first file in the list. | |
| 327 | if( i == activeIndex ) { | |
| 328 | getSelectionModel().select( fileEditorTab ); | |
| 329 | } | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * Returns a property that changes when a new definition file is opened. | |
| 335 | * | |
| 336 | * @return The path to a definition file that was opened. | |
| 337 | */ | |
| 338 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 339 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 340 | } | |
| 341 | ||
| 342 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 343 | return this.openDefinition; | |
| 344 | } | |
| 345 | ||
| 346 | /** | |
| 347 | * Called when the user has opened a definition file (using the file open | |
| 348 | * dialog box). This will replace the current set of definitions for the | |
| 349 | * active tab. | |
| 350 | * | |
| 351 | * @param definition The file to open. | |
| 352 | */ | |
| 353 | private void openDefinition( final File definition ) { | |
| 354 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 355 | // (might be a matter of checking the value first). | |
| 356 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Called when the contents of the editor are to be saved. | |
| 361 | * | |
| 362 | * @param tab The tab containing content to save. | |
| 363 | * @return true The contents were saved (or needn't be saved). | |
| 364 | */ | |
| 365 | public boolean saveEditor( final FileEditorTab tab ) { | |
| 366 | if( tab == null || !tab.isModified() ) { | |
| 367 | return true; | |
| 368 | } | |
| 369 | ||
| 370 | return tab.getPath() == null ? saveEditorAs( tab ) : tab.save(); | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Opens the Save As dialog for the user to save the content under a new | |
| 375 | * path. | |
| 376 | * | |
| 377 | * @param tab The tab with contents to save. | |
| 378 | * @return true The contents were saved, or the tab was null. | |
| 379 | */ | |
| 380 | public boolean saveEditorAs( final FileEditorTab tab ) { | |
| 381 | if( tab == null ) { | |
| 382 | return true; | |
| 383 | } | |
| 384 | ||
| 385 | getSelectionModel().select( tab ); | |
| 386 | ||
| 387 | final FileChooser fileChooser = createFileChooser( get( | |
| 388 | "Dialog.file.choose.save.title" ) ); | |
| 389 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 390 | if( file == null ) { | |
| 391 | return false; | |
| 392 | } | |
| 393 | ||
| 394 | saveLastDirectory( file ); | |
| 395 | tab.setPath( file.toPath() ); | |
| 396 | ||
| 397 | return tab.save(); | |
| 398 | } | |
| 399 | ||
| 400 | void saveAllEditors() { | |
| 401 | for( final FileEditorTab fileEditor : getAllEditors() ) { | |
| 402 | saveEditor( fileEditor ); | |
| 403 | } | |
| 404 | } | |
| 405 | ||
| 406 | /** | |
| 407 | * Answers whether the file has had modifications. ' | |
| 408 | * | |
| 409 | * @param tab THe tab to check for modifications. | |
| 410 | * @return false The file is unmodified. | |
| 411 | */ | |
| 412 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 413 | if( !tab.isModified() ) { | |
| 414 | return true; | |
| 415 | } | |
| 416 | ||
| 417 | final Notification message = getNotifyService().createNotification( | |
| 418 | Messages.get( "Alert.file.close.title" ), | |
| 419 | Messages.get( "Alert.file.close.text" ), | |
| 420 | tab.getText() | |
| 421 | ); | |
| 422 | ||
| 423 | final Alert alert = getNotifyService().createConfirmation( | |
| 424 | getWindow(), message ); | |
| 425 | final ButtonType response = alert.showAndWait().get(); | |
| 426 | ||
| 427 | return response == YES ? saveEditor( tab ) : response == NO; | |
| 50 | import javafx.stage.FileChooser; | |
| 51 | import javafx.stage.FileChooser.ExtensionFilter; | |
| 52 | import javafx.stage.Window; | |
| 53 | import org.fxmisc.richtext.StyledTextArea; | |
| 54 | ||
| 55 | import java.io.File; | |
| 56 | import java.nio.file.Path; | |
| 57 | import java.util.ArrayList; | |
| 58 | import java.util.List; | |
| 59 | import java.util.Optional; | |
| 60 | import java.util.concurrent.atomic.AtomicReference; | |
| 61 | import java.util.prefs.Preferences; | |
| 62 | import java.util.stream.Collectors; | |
| 63 | ||
| 64 | import static com.scrivenvar.Constants.GLOB_PREFIX_FILE; | |
| 65 | import static com.scrivenvar.FileType.*; | |
| 66 | import static com.scrivenvar.Messages.get; | |
| 67 | import static com.scrivenvar.service.events.Notifier.YES; | |
| 68 | ||
| 69 | /** | |
| 70 | * Tab pane for file editors. | |
| 71 | * | |
| 72 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 73 | */ | |
| 74 | public final class FileEditorTabPane extends TabPane { | |
| 75 | ||
| 76 | private final static String FILTER_EXTENSION_TITLES = "Dialog.file.choose" + | |
| 77 | ".filter"; | |
| 78 | ||
| 79 | private final Options options = Services.load( Options.class ); | |
| 80 | private final Settings settings = Services.load( Settings.class ); | |
| 81 | private final Notifier notifyService = Services.load( Notifier.class ); | |
| 82 | ||
| 83 | private final ReadOnlyObjectWrapper<Path> openDefinition = | |
| 84 | new ReadOnlyObjectWrapper<>(); | |
| 85 | private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor = | |
| 86 | new ReadOnlyObjectWrapper<>(); | |
| 87 | private final ReadOnlyBooleanWrapper anyFileEditorModified = | |
| 88 | new ReadOnlyBooleanWrapper(); | |
| 89 | ||
| 90 | /** | |
| 91 | * Constructs a new file editor tab pane. | |
| 92 | */ | |
| 93 | public FileEditorTabPane() { | |
| 94 | final ObservableList<Tab> tabs = getTabs(); | |
| 95 | ||
| 96 | setFocusTraversable( false ); | |
| 97 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 98 | ||
| 99 | addTabSelectionListener( | |
| 100 | ( ObservableValue<? extends Tab> tabPane, | |
| 101 | final Tab oldTab, final Tab newTab ) -> { | |
| 102 | ||
| 103 | if( newTab != null ) { | |
| 104 | mActiveFileEditor.set( (FileEditorTab) newTab ); | |
| 105 | } | |
| 106 | } | |
| 107 | ); | |
| 108 | ||
| 109 | final ChangeListener<Boolean> modifiedListener = ( observable, oldValue, | |
| 110 | newValue ) -> { | |
| 111 | for( final Tab tab : tabs ) { | |
| 112 | if( ((FileEditorTab) tab).isModified() ) { | |
| 113 | this.anyFileEditorModified.set( true ); | |
| 114 | break; | |
| 115 | } | |
| 116 | } | |
| 117 | }; | |
| 118 | ||
| 119 | tabs.addListener( | |
| 120 | (ListChangeListener<Tab>) change -> { | |
| 121 | while( change.next() ) { | |
| 122 | if( change.wasAdded() ) { | |
| 123 | change.getAddedSubList().forEach( | |
| 124 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 125 | .addListener( modifiedListener ) ); | |
| 126 | } | |
| 127 | else if( change.wasRemoved() ) { | |
| 128 | change.getRemoved().forEach( | |
| 129 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 130 | .removeListener( | |
| 131 | modifiedListener ) ); | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | // Changes in the tabs may also change anyFileEditorModified property | |
| 136 | // (e.g. closed modified file) | |
| 137 | modifiedListener.changed( null, null, null ); | |
| 138 | } | |
| 139 | ); | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Allows observers to be notified when the current file editor tab changes. | |
| 144 | * | |
| 145 | * @param listener The listener to notify of tab change events. | |
| 146 | */ | |
| 147 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 148 | // Observe the tab so that when a new tab is opened or selected, | |
| 149 | // a notification is kicked off. | |
| 150 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Allows clients to manipulate the editor content directly. | |
| 155 | * | |
| 156 | * @return The text area for the active file editor. | |
| 157 | */ | |
| 158 | public StyledTextArea getEditor() { | |
| 159 | return getActiveFileEditor().getEditorPane().getEditor(); | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Returns the tab that has keyboard focus. | |
| 164 | * | |
| 165 | * @return A non-null instance. | |
| 166 | */ | |
| 167 | public FileEditorTab getActiveFileEditor() { | |
| 168 | return mActiveFileEditor.get(); | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Returns the property corresponding to the tab that has focus. | |
| 173 | * | |
| 174 | * @return A non-null instance. | |
| 175 | */ | |
| 176 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 177 | return mActiveFileEditor.getReadOnlyProperty(); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Property that can answer whether the text has been modified. | |
| 182 | * | |
| 183 | * @return A non-null instance, true meaning the content has not been saved. | |
| 184 | */ | |
| 185 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 186 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Creates a new editor instance from the given path. | |
| 191 | * | |
| 192 | * @param path The file to open. | |
| 193 | * @return A non-null instance. | |
| 194 | */ | |
| 195 | private FileEditorTab createFileEditor( final Path path ) { | |
| 196 | assert path != null; | |
| 197 | ||
| 198 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 199 | ||
| 200 | tab.setOnCloseRequest( e -> { | |
| 201 | if( !canCloseEditor( tab ) ) { | |
| 202 | e.consume(); | |
| 203 | } | |
| 204 | else if( isActiveFileEditor( tab ) ) { | |
| 205 | // Prevent prompting the user to save when there are no file editor | |
| 206 | // tabs open. | |
| 207 | mActiveFileEditor.set( null ); | |
| 208 | } | |
| 209 | } ); | |
| 210 | ||
| 211 | return tab; | |
| 212 | } | |
| 213 | ||
| 214 | private boolean isActiveFileEditor( final FileEditorTab tab ) { | |
| 215 | return getActiveFileEditor() == tab; | |
| 216 | } | |
| 217 | ||
| 218 | private Path getDefaultPath() { | |
| 219 | final String filename = getDefaultFilename(); | |
| 220 | return (new File( filename )).toPath(); | |
| 221 | } | |
| 222 | ||
| 223 | private String getDefaultFilename() { | |
| 224 | return getSettings().getSetting( "file.default", "untitled.md" ); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Called when the user selects New from the File menu. | |
| 229 | */ | |
| 230 | void newEditor() { | |
| 231 | final Path defaultPath = getDefaultPath(); | |
| 232 | final FileEditorTab tab = createFileEditor( defaultPath ); | |
| 233 | ||
| 234 | getTabs().add( tab ); | |
| 235 | getSelectionModel().select( tab ); | |
| 236 | } | |
| 237 | ||
| 238 | void openFileDialog() { | |
| 239 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 240 | final FileChooser dialog = createFileChooser( title ); | |
| 241 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 242 | ||
| 243 | if( files != null ) { | |
| 244 | openFiles( files ); | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Opens the files into new editors, unless one of those files was a | |
| 250 | * definition file. The definition file is loaded into the definition pane, | |
| 251 | * but only the first one selected (multiple definition files will result in a | |
| 252 | * warning). | |
| 253 | * | |
| 254 | * @param files The list of non-definition files that the were requested to | |
| 255 | * open. | |
| 256 | */ | |
| 257 | private void openFiles( final List<File> files ) { | |
| 258 | final List<String> extensions = | |
| 259 | createExtensionFilter( DEFINITION ).getExtensions(); | |
| 260 | final FileTypePredicate predicate = | |
| 261 | new FileTypePredicate( extensions ); | |
| 262 | ||
| 263 | // The user might have opened multiple definitions files. These will | |
| 264 | // be discarded from the text editable files. | |
| 265 | final List<File> definitions | |
| 266 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 267 | ||
| 268 | // Create a modifiable list to remove any definition files that were | |
| 269 | // opened. | |
| 270 | final List<File> editors = new ArrayList<>( files ); | |
| 271 | ||
| 272 | if( !editors.isEmpty() ) { | |
| 273 | saveLastDirectory( editors.get( 0 ) ); | |
| 274 | } | |
| 275 | ||
| 276 | editors.removeAll( definitions ); | |
| 277 | ||
| 278 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 279 | if( !editors.isEmpty() ) { | |
| 280 | openEditors( editors, 0 ); | |
| 281 | } | |
| 282 | ||
| 283 | if( !definitions.isEmpty() ) { | |
| 284 | openDefinition( definitions.get( 0 ) ); | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 289 | final int fileTally = files.size(); | |
| 290 | final List<Tab> tabs = getTabs(); | |
| 291 | ||
| 292 | // Close single unmodified "Untitled" tab. | |
| 293 | if( tabs.size() == 1 ) { | |
| 294 | final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 )); | |
| 295 | ||
| 296 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 297 | closeEditor( fileEditor, false ); | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | for( int i = 0; i < fileTally; i++ ) { | |
| 302 | final Path path = files.get( i ).toPath(); | |
| 303 | ||
| 304 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 305 | ||
| 306 | // Only open new files. | |
| 307 | if( fileEditorTab == null ) { | |
| 308 | fileEditorTab = createFileEditor( path ); | |
| 309 | getTabs().add( fileEditorTab ); | |
| 310 | } | |
| 311 | ||
| 312 | // Select the first file in the list. | |
| 313 | if( i == activeIndex ) { | |
| 314 | getSelectionModel().select( fileEditorTab ); | |
| 315 | } | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Returns a property that changes when a new definition file is opened. | |
| 321 | * | |
| 322 | * @return The path to a definition file that was opened. | |
| 323 | */ | |
| 324 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 325 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 326 | } | |
| 327 | ||
| 328 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 329 | return this.openDefinition; | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Called when the user has opened a definition file (using the file open | |
| 334 | * dialog box). This will replace the current set of definitions for the | |
| 335 | * active tab. | |
| 336 | * | |
| 337 | * @param definition The file to open. | |
| 338 | */ | |
| 339 | private void openDefinition( final File definition ) { | |
| 340 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 341 | // (might be a matter of checking the value first). | |
| 342 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Called when the contents of the editor are to be saved. | |
| 347 | * | |
| 348 | * @param tab The tab containing content to save. | |
| 349 | * @return true The contents were saved (or needn't be saved). | |
| 350 | */ | |
| 351 | public boolean saveEditor( final FileEditorTab tab ) { | |
| 352 | if( tab == null || !tab.isModified() ) { | |
| 353 | return true; | |
| 354 | } | |
| 355 | ||
| 356 | return tab.getPath() == null ? saveEditorAs( tab ) : tab.save(); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Opens the Save As dialog for the user to save the content under a new | |
| 361 | * path. | |
| 362 | * | |
| 363 | * @param tab The tab with contents to save. | |
| 364 | * @return true The contents were saved, or the tab was null. | |
| 365 | */ | |
| 366 | public boolean saveEditorAs( final FileEditorTab tab ) { | |
| 367 | if( tab == null ) { | |
| 368 | return true; | |
| 369 | } | |
| 370 | ||
| 371 | getSelectionModel().select( tab ); | |
| 372 | ||
| 373 | final FileChooser fileChooser = createFileChooser( get( | |
| 374 | "Dialog.file.choose.save.title" ) ); | |
| 375 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 376 | if( file == null ) { | |
| 377 | return false; | |
| 378 | } | |
| 379 | ||
| 380 | saveLastDirectory( file ); | |
| 381 | tab.setPath( file.toPath() ); | |
| 382 | ||
| 383 | return tab.save(); | |
| 384 | } | |
| 385 | ||
| 386 | void saveAllEditors() { | |
| 387 | for( final FileEditorTab fileEditor : getAllEditors() ) { | |
| 388 | saveEditor( fileEditor ); | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | /** | |
| 393 | * Answers whether the file has had modifications. ' | |
| 394 | * | |
| 395 | * @param tab THe tab to check for modifications. | |
| 396 | * @return false The file is unmodified. | |
| 397 | */ | |
| 398 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 399 | final AtomicReference<Boolean> canClose = new AtomicReference<>(); | |
| 400 | canClose.set( true ); | |
| 401 | ||
| 402 | if( tab.isModified() ) { | |
| 403 | final Notification message = getNotifyService().createNotification( | |
| 404 | Messages.get( "Alert.file.close.title" ), | |
| 405 | Messages.get( "Alert.file.close.text" ), | |
| 406 | tab.getText() | |
| 407 | ); | |
| 408 | ||
| 409 | final Alert confirmSave = getNotifyService().createConfirmation( | |
| 410 | getWindow(), message ); | |
| 411 | ||
| 412 | final Optional<ButtonType> buttonType = confirmSave.showAndWait(); | |
| 413 | ||
| 414 | buttonType.ifPresent( | |
| 415 | save -> canClose.set( | |
| 416 | save == YES ? saveEditor( tab ) : save == ButtonType.NO | |
| 417 | ) | |
| 418 | ); | |
| 419 | } | |
| 420 | ||
| 421 | return canClose.get(); | |
| 428 | 422 | } |
| 429 | 423 |
| 79 | 79 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*; |
| 80 | 80 | import static javafx.event.Event.fireEvent; |
| 81 | import static javafx.scene.input.KeyCode.ESCAPE; | |
| 82 | import static javafx.scene.input.KeyEvent.CHAR_UNDEFINED; | |
| 83 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 84 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 85 | ||
| 86 | /** | |
| 87 | * Main window containing a tab pane in the center for file editors. | |
| 88 | * | |
| 89 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 90 | */ | |
| 91 | public class MainWindow implements Observer { | |
| 92 | ||
| 93 | private final Options mOptions = Services.load( Options.class ); | |
| 94 | private final Snitch mSnitch = Services.load( Snitch.class ); | |
| 95 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 96 | ||
| 97 | private Scene scene; | |
| 98 | private MenuBar menuBar; | |
| 99 | private StatusBar statusBar; | |
| 100 | private Text lineNumberText; | |
| 101 | private TextField findTextField; | |
| 102 | ||
| 103 | private DefinitionSource definitionSource; | |
| 104 | private DefinitionPane definitionPane; | |
| 105 | private FileEditorTabPane fileEditorPane; | |
| 106 | private HTMLPreviewPane previewPane; | |
| 107 | ||
| 108 | /** | |
| 109 | * Prevents re-instantiation of processing classes. | |
| 110 | */ | |
| 111 | private Map<FileEditorTab, Processor<String>> processors; | |
| 112 | ||
| 113 | /** | |
| 114 | * Listens on the definition pane for double-click events. | |
| 115 | */ | |
| 116 | private VariableNameInjector variableNameInjector; | |
| 117 | ||
| 118 | public MainWindow() { | |
| 119 | initLayout(); | |
| 120 | initFindInput(); | |
| 121 | initSnitch(); | |
| 122 | initDefinitionListener(); | |
| 123 | initTabAddedListener(); | |
| 124 | initTabChangedListener(); | |
| 125 | initPreferences(); | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Watch for changes to external files. In particular, this awaits | |
| 130 | * modifications to any XSL files associated with XML files being edited. When | |
| 131 | * an XSL file is modified (external to the application), the snitch's ears | |
| 132 | * perk up and the file is reloaded. This keeps the XSL transformation up to | |
| 133 | * date with what's on the file system. | |
| 134 | */ | |
| 135 | private void initSnitch() { | |
| 136 | getSnitch().addObserver( this ); | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key | |
| 141 | * presses. | |
| 142 | */ | |
| 143 | private void initFindInput() { | |
| 144 | final TextField input = getFindTextField(); | |
| 145 | ||
| 146 | input.setOnKeyPressed( ( KeyEvent event ) -> { | |
| 147 | switch( event.getCode() ) { | |
| 148 | case F3: | |
| 149 | case ENTER: | |
| 150 | findNext(); | |
| 151 | break; | |
| 152 | case F: | |
| 153 | if( !event.isControlDown() ) { | |
| 154 | break; | |
| 155 | } | |
| 156 | case ESCAPE: | |
| 157 | getStatusBar().setGraphic( null ); | |
| 158 | getActiveFileEditor().getEditorPane().requestFocus(); | |
| 159 | break; | |
| 160 | } | |
| 161 | } ); | |
| 162 | ||
| 163 | // Remove when the input field loses focus. | |
| 164 | input.focusedProperty().addListener( | |
| 165 | ( | |
| 166 | final ObservableValue<? extends Boolean> focused, | |
| 167 | final Boolean oFocus, | |
| 168 | final Boolean nFocus ) -> { | |
| 169 | if( !nFocus ) { | |
| 170 | getStatusBar().setGraphic( null ); | |
| 171 | } | |
| 172 | } | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * Listen for file editor tab pane to receive an open definition source event. | |
| 178 | */ | |
| 179 | private void initDefinitionListener() { | |
| 180 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 181 | ( ObservableValue<? extends Path> definitionFile, | |
| 182 | final Path oldPath, final Path newPath ) -> { | |
| 183 | openDefinition( newPath ); | |
| 184 | ||
| 185 | // Indirectly refresh the resolved map. | |
| 186 | setProcessors( null ); | |
| 187 | updateDefinitionPane(); | |
| 188 | ||
| 189 | try { | |
| 190 | getSnitch().ignore( oldPath ); | |
| 191 | getSnitch().listen( newPath ); | |
| 192 | } catch( final IOException ex ) { | |
| 193 | error( ex ); | |
| 194 | } | |
| 195 | ||
| 196 | // Will create new processors and therefore a new resolved map. | |
| 197 | refreshSelectedTab( getActiveFileEditor() ); | |
| 198 | } | |
| 199 | ); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 204 | * that the preview pane refreshes as necessary. | |
| 205 | */ | |
| 206 | private void initTabAddedListener() { | |
| 207 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 208 | ||
| 209 | // Make sure the text processor kicks off when new files are opened. | |
| 210 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 211 | ||
| 212 | // Update the preview pane on tab changes. | |
| 213 | tabs.addListener( | |
| 214 | ( final Change<? extends Tab> change ) -> { | |
| 215 | while( change.next() ) { | |
| 216 | if( change.wasAdded() ) { | |
| 217 | // Multiple tabs can be added simultaneously. | |
| 218 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 219 | final FileEditorTab tab = (FileEditorTab) newTab; | |
| 220 | ||
| 221 | initTextChangeListener( tab ); | |
| 222 | initCaretParagraphListener( tab ); | |
| 223 | initKeyboardEventListeners( tab ); | |
| 224 | // initSyntaxListener( tab ); | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 229 | ); | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * Reloads the preferences from the previous session. | |
| 234 | */ | |
| 235 | private void initPreferences() { | |
| 236 | restoreDefinitionSource(); | |
| 237 | getFileEditorPane().restorePreferences(); | |
| 238 | updateDefinitionPane(); | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * Listen for new tab selection events. | |
| 243 | */ | |
| 244 | private void initTabChangedListener() { | |
| 245 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 246 | ||
| 247 | // Update the preview pane changing tabs. | |
| 248 | editorPane.addTabSelectionListener( | |
| 249 | ( ObservableValue<? extends Tab> tabPane, | |
| 250 | final Tab oldTab, final Tab newTab ) -> { | |
| 251 | updateVariableNameInjector(); | |
| 252 | ||
| 253 | // If there was no old tab, then this is a first time load, which | |
| 254 | // can be ignored. | |
| 255 | if( oldTab != null ) { | |
| 256 | if( newTab == null ) { | |
| 257 | closeRemainingTab(); | |
| 258 | } | |
| 259 | else { | |
| 260 | // Update the preview with the edited text. | |
| 261 | refreshSelectedTab( (FileEditorTab) newTab ); | |
| 262 | } | |
| 263 | } | |
| 264 | } | |
| 265 | ); | |
| 266 | } | |
| 267 | ||
| 268 | /** | |
| 269 | * Ensure that the keyboard events are received when a new tab is added | |
| 270 | * to the user interface. | |
| 271 | * | |
| 272 | * @param tab The tab that can trigger keyboard events, such as control+space. | |
| 273 | */ | |
| 274 | private void initKeyboardEventListeners( final FileEditorTab tab ) { | |
| 275 | final VariableNameInjector vin = getVariableNameInjector(); | |
| 276 | vin.initKeyboardEventListeners( tab ); | |
| 277 | } | |
| 278 | ||
| 279 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 280 | tab.addTextChangeListener( | |
| 281 | ( ObservableValue<? extends String> editor, | |
| 282 | final String oldValue, final String newValue ) -> | |
| 283 | refreshSelectedTab( tab ) | |
| 284 | ); | |
| 285 | } | |
| 286 | ||
| 287 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 288 | tab.addCaretParagraphListener( | |
| 289 | ( ObservableValue<? extends Integer> editor, | |
| 290 | final Integer oldValue, final Integer newValue ) -> | |
| 291 | refreshSelectedTab( tab ) | |
| 292 | ); | |
| 293 | } | |
| 294 | ||
| 295 | private void updateVariableNameInjector() { | |
| 296 | getVariableNameInjector().setFileEditorTab( getActiveFileEditor() ); | |
| 297 | } | |
| 298 | ||
| 299 | private void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 300 | this.variableNameInjector = injector; | |
| 301 | } | |
| 302 | ||
| 303 | private synchronized VariableNameInjector getVariableNameInjector() { | |
| 304 | if( this.variableNameInjector == null ) { | |
| 305 | final VariableNameInjector vin = createVariableNameInjector(); | |
| 306 | setVariableNameInjector( vin ); | |
| 307 | } | |
| 308 | ||
| 309 | return this.variableNameInjector; | |
| 310 | } | |
| 311 | ||
| 312 | private VariableNameInjector createVariableNameInjector() { | |
| 313 | final FileEditorTab tab = getActiveFileEditor(); | |
| 314 | final DefinitionPane pane = getDefinitionPane(); | |
| 315 | ||
| 316 | return new VariableNameInjector( tab, pane ); | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Add a listener for variable name injection the given tab. | |
| 321 | * | |
| 322 | * @param tab The tab to inject variable names into upon a double-click. | |
| 323 | */ | |
| 324 | private void initVariableNameInjector( final Tab tab ) { | |
| 325 | final FileEditorTab editorTab = (FileEditorTab) tab; | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 330 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 331 | * or the file tab changes. | |
| 332 | * | |
| 333 | * @param tab The file editor tab that has been changed in some fashion. | |
| 334 | */ | |
| 335 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 336 | if( tab.isFileOpen() ) { | |
| 337 | getPreviewPane().setPath( tab.getPath() ); | |
| 338 | ||
| 339 | // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29 | |
| 340 | final Position p = tab.getCaretOffset(); | |
| 341 | getLineNumberText().setText( | |
| 342 | get( STATUS_BAR_LINE, | |
| 343 | p.getMajor() + 1, | |
| 344 | p.getMinor() + 1, | |
| 345 | tab.getCaretPosition() + 1 | |
| 346 | ) | |
| 347 | ); | |
| 348 | ||
| 349 | Processor<String> processor = getProcessors().get( tab ); | |
| 350 | ||
| 351 | if( processor == null ) { | |
| 352 | processor = createProcessor( tab ); | |
| 353 | getProcessors().put( tab, processor ); | |
| 354 | } | |
| 355 | ||
| 356 | try { | |
| 357 | getNotifier().clear(); | |
| 358 | processor.processChain( tab.getEditorText() ); | |
| 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 | } catch( Exception e ) { | |
| 396 | error( e ); | |
| 397 | } | |
| 398 | ||
| 399 | // Slightly redundant as getDefinitionSource() might have returned an | |
| 400 | // empty definition source. | |
| 401 | return (new EmptyDefinitionSource()).asTreeView(); | |
| 402 | } | |
| 403 | ||
| 404 | /** | |
| 405 | * Called when a definition source is opened. | |
| 406 | * | |
| 407 | * @param path Path to the definition source that was opened. | |
| 408 | */ | |
| 409 | private void openDefinition( final Path path ) { | |
| 410 | try { | |
| 411 | final DefinitionSource ds = createDefinitionSource( path.toString() ); | |
| 412 | setDefinitionSource( ds ); | |
| 413 | storeDefinitionSource(); | |
| 414 | updateDefinitionPane(); | |
| 415 | } catch( final Exception e ) { | |
| 416 | error( e ); | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | private void updateDefinitionPane() { | |
| 421 | getDefinitionPane().setRoot( getDefinitionSource().asTreeView() ); | |
| 422 | } | |
| 423 | ||
| 424 | private void restoreDefinitionSource() { | |
| 425 | final Preferences preferences = getPreferences(); | |
| 426 | final String source = preferences.get( PERSIST_DEFINITION_SOURCE, "" ); | |
| 427 | ||
| 428 | setDefinitionSource( createDefinitionSource( source ) ); | |
| 429 | } | |
| 430 | ||
| 431 | private void storeDefinitionSource() { | |
| 432 | final Preferences preferences = getPreferences(); | |
| 433 | final DefinitionSource ds = getDefinitionSource(); | |
| 434 | ||
| 435 | preferences.put( PERSIST_DEFINITION_SOURCE, ds.toString() ); | |
| 436 | } | |
| 437 | ||
| 438 | /** | |
| 439 | * Called when the last open tab is closed to clear the preview pane. | |
| 440 | */ | |
| 441 | private void closeRemainingTab() { | |
| 442 | getPreviewPane().clear(); | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Called when an exception occurs that warrants the user's attention. | |
| 447 | * | |
| 448 | * @param e The exception with a message that the user should know about. | |
| 449 | */ | |
| 450 | private void error( final Exception e ) { | |
| 451 | getNotifier().notify( e ); | |
| 452 | } | |
| 453 | ||
| 454 | //---- File actions ------------------------------------------------------- | |
| 455 | ||
| 456 | /** | |
| 457 | * Called when an observable instance has changed. This is called by both the | |
| 458 | * snitch service and the notify service. The snitch service can be called for | |
| 459 | * different file types, including definition sources. | |
| 460 | * | |
| 461 | * @param observable The observed instance. | |
| 462 | * @param value The noteworthy item. | |
| 463 | */ | |
| 464 | @Override | |
| 465 | public void update( final Observable observable, final Object value ) { | |
| 466 | if( value != null ) { | |
| 467 | if( observable instanceof Snitch && value instanceof Path ) { | |
| 468 | final Path path = (Path) value; | |
| 469 | final FileTypePredicate predicate | |
| 470 | = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS ); | |
| 471 | ||
| 472 | // Reload definitions. | |
| 473 | if( predicate.test( path.toFile() ) ) { | |
| 474 | updateDefinitionSource( path ); | |
| 475 | } | |
| 476 | ||
| 477 | updateSelectedTab(); | |
| 478 | } | |
| 479 | else if( observable instanceof Notifier && value instanceof String ) { | |
| 480 | updateStatusBar( (String) value ); | |
| 481 | } | |
| 482 | } | |
| 483 | } | |
| 484 | ||
| 485 | /** | |
| 486 | * Updates the status bar to show the given message. | |
| 487 | * | |
| 488 | * @param s The message to show in the status bar. | |
| 489 | */ | |
| 490 | private void updateStatusBar( final String s ) { | |
| 491 | Platform.runLater( | |
| 492 | () -> { | |
| 493 | final int index = s.indexOf( '\n' ); | |
| 494 | final String message = s.substring( 0, | |
| 495 | index > 0 ? index : s.length() ); | |
| 496 | ||
| 497 | getStatusBar().setText( message ); | |
| 498 | } | |
| 499 | ); | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * Called when a file has been modified. | |
| 504 | */ | |
| 505 | private void updateSelectedTab() { | |
| 506 | Platform.runLater( | |
| 507 | () -> { | |
| 508 | // Brute-force XSLT file reload by re-instantiating all processors. | |
| 509 | resetProcessors(); | |
| 510 | refreshSelectedTab( getActiveFileEditor() ); | |
| 511 | } | |
| 512 | ); | |
| 513 | } | |
| 514 | ||
| 515 | /** | |
| 516 | * Reloads the definition source from the given path. | |
| 517 | * | |
| 518 | * @param path The path containing new definition information. | |
| 519 | */ | |
| 520 | private void updateDefinitionSource( final Path path ) { | |
| 521 | Platform.runLater( () -> openDefinition( path ) ); | |
| 522 | } | |
| 523 | ||
| 524 | /** | |
| 525 | * After resetting the processors, they will refresh anew to be up-to-date | |
| 526 | * with the files (text and definition) currently loaded into the editor. | |
| 527 | */ | |
| 528 | private void resetProcessors() { | |
| 529 | getProcessors().clear(); | |
| 530 | } | |
| 531 | ||
| 532 | //---- File actions ------------------------------------------------------- | |
| 533 | private void fileNew() { | |
| 534 | getFileEditorPane().newEditor(); | |
| 535 | } | |
| 536 | ||
| 537 | private void fileOpen() { | |
| 538 | getFileEditorPane().openFileDialog(); | |
| 539 | } | |
| 540 | ||
| 541 | private void fileClose() { | |
| 542 | getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | |
| 543 | } | |
| 544 | ||
| 545 | private void fileCloseAll() { | |
| 546 | getFileEditorPane().closeAllEditors(); | |
| 547 | } | |
| 548 | ||
| 549 | private void fileSave() { | |
| 550 | getFileEditorPane().saveEditor( getActiveFileEditor() ); | |
| 551 | } | |
| 552 | ||
| 553 | private void fileSaveAs() { | |
| 554 | final FileEditorTab editor = getActiveFileEditor(); | |
| 555 | getFileEditorPane().saveEditorAs( editor ); | |
| 556 | getProcessors().remove( editor ); | |
| 557 | ||
| 558 | try { | |
| 559 | refreshSelectedTab( editor ); | |
| 560 | } catch( final Exception ex ) { | |
| 561 | getNotifier().notify( ex ); | |
| 562 | } | |
| 563 | } | |
| 564 | ||
| 565 | private void fileSaveAll() { | |
| 566 | getFileEditorPane().saveAllEditors(); | |
| 567 | } | |
| 568 | ||
| 569 | private void fileExit() { | |
| 570 | final Window window = getWindow(); | |
| 571 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 572 | } | |
| 573 | ||
| 574 | //---- R menu actions | |
| 575 | private void rScript() { | |
| 576 | final String script = getPreferences().get( PERSIST_R_STARTUP, "" ); | |
| 577 | final RScriptDialog dialog = new RScriptDialog( | |
| 578 | getWindow(), "Dialog.r.script.title", script ); | |
| 579 | final Optional<String> result = dialog.showAndWait(); | |
| 580 | ||
| 581 | result.ifPresent( this::putStartupScript ); | |
| 582 | } | |
| 583 | ||
| 584 | private void rDirectory() { | |
| 585 | final TextInputDialog dialog = new TextInputDialog( | |
| 586 | getPreferences().get( PERSIST_R_DIRECTORY, USER_DIRECTORY ) | |
| 587 | ); | |
| 588 | ||
| 589 | dialog.setTitle( get( "Dialog.r.directory.title" ) ); | |
| 590 | dialog.setHeaderText( getLiteral( "Dialog.r.directory.header" ) ); | |
| 591 | dialog.setContentText( "Directory" ); | |
| 592 | ||
| 593 | final Optional<String> result = dialog.showAndWait(); | |
| 594 | ||
| 595 | result.ifPresent( this::putStartupDirectory ); | |
| 596 | } | |
| 597 | ||
| 598 | /** | |
| 599 | * Stores the R startup script into the user preferences. | |
| 600 | */ | |
| 601 | private void putStartupScript( final String script ) { | |
| 602 | putPreference( PERSIST_R_STARTUP, script ); | |
| 603 | } | |
| 604 | ||
| 605 | /** | |
| 606 | * Stores the R bootstrap script directory into the user preferences. | |
| 607 | */ | |
| 608 | private void putStartupDirectory( final String directory ) { | |
| 609 | putPreference( PERSIST_R_DIRECTORY, directory ); | |
| 610 | } | |
| 611 | ||
| 612 | //---- Help actions ------------------------------------------------------- | |
| 613 | private void helpAbout() { | |
| 614 | Alert alert = new Alert( AlertType.INFORMATION ); | |
| 615 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 616 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 617 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 618 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 619 | alert.initOwner( getWindow() ); | |
| 620 | ||
| 621 | alert.showAndWait(); | |
| 622 | } | |
| 623 | ||
| 624 | //---- Convenience accessors ---------------------------------------------- | |
| 625 | private float getFloat( final String key, final float defaultValue ) { | |
| 626 | return getPreferences().getFloat( key, defaultValue ); | |
| 627 | } | |
| 628 | ||
| 629 | private Preferences getPreferences() { | |
| 630 | return getOptions().getState(); | |
| 631 | } | |
| 632 | ||
| 633 | protected Scene getScene() { | |
| 634 | if( this.scene == null ) { | |
| 635 | this.scene = createScene(); | |
| 636 | } | |
| 637 | ||
| 638 | return this.scene; | |
| 639 | } | |
| 640 | ||
| 641 | public Window getWindow() { | |
| 642 | return getScene().getWindow(); | |
| 643 | } | |
| 644 | ||
| 645 | private MarkdownEditorPane getActiveEditor() { | |
| 646 | final EditorPane pane = getActiveFileEditor().getEditorPane(); | |
| 647 | ||
| 648 | return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane) pane : | |
| 649 | null; | |
| 650 | } | |
| 651 | ||
| 652 | private FileEditorTab getActiveFileEditor() { | |
| 653 | return getFileEditorPane().getActiveFileEditor(); | |
| 654 | } | |
| 655 | ||
| 656 | //---- Member accessors --------------------------------------------------- | |
| 657 | private void setProcessors( | |
| 658 | final Map<FileEditorTab, Processor<String>> map ) { | |
| 659 | this.processors = map; | |
| 660 | } | |
| 661 | ||
| 662 | private Map<FileEditorTab, Processor<String>> getProcessors() { | |
| 663 | if( this.processors == null ) { | |
| 664 | setProcessors( new HashMap<>() ); | |
| 665 | } | |
| 666 | ||
| 667 | return this.processors; | |
| 668 | } | |
| 669 | ||
| 670 | private FileEditorTabPane getFileEditorPane() { | |
| 671 | if( this.fileEditorPane == null ) { | |
| 672 | this.fileEditorPane = createFileEditorPane(); | |
| 673 | } | |
| 674 | ||
| 675 | return this.fileEditorPane; | |
| 676 | } | |
| 677 | ||
| 678 | private HTMLPreviewPane getPreviewPane() { | |
| 679 | if( this.previewPane == null ) { | |
| 680 | this.previewPane = createPreviewPane(); | |
| 681 | } | |
| 682 | ||
| 683 | return this.previewPane; | |
| 684 | } | |
| 685 | ||
| 686 | private void setDefinitionSource( final DefinitionSource definitionSource ) { | |
| 687 | this.definitionSource = definitionSource; | |
| 688 | } | |
| 689 | ||
| 690 | private DefinitionSource getDefinitionSource() { | |
| 691 | if( this.definitionSource == null ) { | |
| 692 | this.definitionSource = new EmptyDefinitionSource(); | |
| 693 | } | |
| 694 | ||
| 695 | return this.definitionSource; | |
| 696 | } | |
| 697 | ||
| 698 | private DefinitionPane getDefinitionPane() { | |
| 699 | if( this.definitionPane == null ) { | |
| 700 | this.definitionPane = createDefinitionPane(); | |
| 701 | } | |
| 702 | ||
| 703 | return this.definitionPane; | |
| 704 | } | |
| 705 | ||
| 706 | private Options getOptions() { | |
| 707 | return mOptions; | |
| 708 | } | |
| 709 | ||
| 710 | private Snitch getSnitch() { | |
| 711 | return mSnitch; | |
| 712 | } | |
| 713 | ||
| 714 | private Notifier getNotifier() { | |
| 715 | return mNotifier; | |
| 716 | } | |
| 717 | ||
| 718 | public void setMenuBar( final MenuBar menuBar ) { | |
| 719 | this.menuBar = menuBar; | |
| 720 | } | |
| 721 | ||
| 722 | public MenuBar getMenuBar() { | |
| 723 | return this.menuBar; | |
| 724 | } | |
| 725 | ||
| 726 | private Text getLineNumberText() { | |
| 727 | if( this.lineNumberText == null ) { | |
| 728 | this.lineNumberText = createLineNumberText(); | |
| 729 | } | |
| 730 | ||
| 731 | return this.lineNumberText; | |
| 732 | } | |
| 733 | ||
| 734 | private synchronized StatusBar getStatusBar() { | |
| 735 | if( this.statusBar == null ) { | |
| 736 | this.statusBar = createStatusBar(); | |
| 737 | } | |
| 738 | ||
| 739 | return this.statusBar; | |
| 740 | } | |
| 741 | ||
| 742 | private TextField getFindTextField() { | |
| 743 | if( this.findTextField == null ) { | |
| 744 | this.findTextField = createFindTextField(); | |
| 745 | } | |
| 746 | ||
| 747 | return this.findTextField; | |
| 748 | } | |
| 749 | ||
| 750 | //---- Member creators ---------------------------------------------------- | |
| 751 | ||
| 752 | /** | |
| 753 | * Factory to create processors that are suited to different file types. | |
| 754 | * | |
| 755 | * @param tab The tab that is subjected to processing. | |
| 756 | * @return A processor suited to the file type specified by the tab's path. | |
| 757 | */ | |
| 758 | private Processor<String> createProcessor( final FileEditorTab tab ) { | |
| 759 | return createProcessorFactory().createProcessor( tab ); | |
| 760 | } | |
| 761 | ||
| 762 | private ProcessorFactory createProcessorFactory() { | |
| 763 | return new ProcessorFactory( getPreviewPane(), getResolvedMap() ); | |
| 764 | } | |
| 765 | ||
| 766 | private DefinitionSource createDefinitionSource( final String path ) { | |
| 767 | DefinitionSource ds; | |
| 768 | ||
| 769 | try { | |
| 770 | ds = createDefinitionFactory().createDefinitionSource( path ); | |
| 771 | ||
| 772 | if( ds instanceof FileDefinitionSource ) { | |
| 773 | try { | |
| 774 | getNotifier().notify( ds.getError() ); | |
| 775 | getSnitch().listen( ((FileDefinitionSource) ds).getPath() ); | |
| 776 | } catch( final Exception ex ) { | |
| 777 | error( ex ); | |
| 778 | } | |
| 779 | } | |
| 780 | } catch( final Exception ex ) { | |
| 781 | ds = new EmptyDefinitionSource(); | |
| 782 | error( ex ); | |
| 783 | } | |
| 784 | ||
| 785 | return ds; | |
| 786 | } | |
| 787 | ||
| 788 | private TextField createFindTextField() { | |
| 789 | return new TextField(); | |
| 790 | } | |
| 791 | ||
| 792 | /** | |
| 793 | * Create an editor pane to hold file editor tabs. | |
| 794 | * | |
| 795 | * @return A new instance, never null. | |
| 796 | */ | |
| 797 | private FileEditorTabPane createFileEditorPane() { | |
| 798 | return new FileEditorTabPane(); | |
| 799 | } | |
| 800 | ||
| 801 | private HTMLPreviewPane createPreviewPane() { | |
| 802 | return new HTMLPreviewPane(); | |
| 803 | } | |
| 804 | ||
| 805 | private DefinitionPane createDefinitionPane() { | |
| 806 | return new DefinitionPane( getTreeView() ); | |
| 807 | } | |
| 808 | ||
| 809 | private DefinitionFactory createDefinitionFactory() { | |
| 810 | return new DefinitionFactory(); | |
| 811 | } | |
| 812 | ||
| 813 | private StatusBar createStatusBar() { | |
| 814 | return new StatusBar(); | |
| 815 | } | |
| 816 | ||
| 817 | private Scene createScene() { | |
| 818 | final SplitPane splitPane = new SplitPane( | |
| 819 | getDefinitionPane().getNode(), | |
| 820 | getFileEditorPane().getNode(), | |
| 821 | getPreviewPane().getNode() ); | |
| 822 | ||
| 823 | splitPane.setDividerPositions( | |
| 824 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 825 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 826 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 827 | ||
| 828 | // See: http://broadlyapplicable.blogspot | |
| 829 | // .ca/2015/03/javafx-capture-restorePreferences-splitpane.html | |
| 830 | final BorderPane borderPane = new BorderPane(); | |
| 831 | borderPane.setPrefSize( 1024, 800 ); | |
| 832 | borderPane.setTop( createMenuBar() ); | |
| 833 | borderPane.setBottom( getStatusBar() ); | |
| 834 | borderPane.setCenter( splitPane ); | |
| 835 | ||
| 836 | final VBox box = new VBox(); | |
| 837 | box.setAlignment( Pos.BASELINE_CENTER ); | |
| 838 | box.getChildren().add( getLineNumberText() ); | |
| 839 | getStatusBar().getRightItems().add( box ); | |
| 840 | ||
| 841 | return new Scene( borderPane ); | |
| 842 | } | |
| 843 | ||
| 844 | private Text createLineNumberText() { | |
| 845 | return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) ); | |
| 846 | } | |
| 847 | ||
| 848 | private Node createMenuBar() { | |
| 849 | final BooleanBinding activeFileEditorIsNull = | |
| 850 | getFileEditorPane().activeFileEditorProperty() | |
| 851 | .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 ) | |
| 872 | .not() ); | |
| 873 | final Action fileSaveAsAction = new Action( Messages.get( | |
| 874 | "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(), | |
| 875 | activeFileEditorIsNull ); | |
| 876 | final Action fileSaveAllAction = new Action( | |
| 877 | get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, | |
| 878 | e -> fileSaveAll(), | |
| 879 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 880 | final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), | |
| 881 | null, | |
| 882 | null, | |
| 883 | e -> fileExit() ); | |
| 884 | ||
| 885 | // Edit actions | |
| 886 | final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), | |
| 887 | "Shortcut+Z", UNDO, | |
| 888 | e -> getActiveEditor().undo(), | |
| 889 | createActiveBooleanProperty( | |
| 890 | FileEditorTab::canUndoProperty ) | |
| 891 | .not() ); | |
| 892 | final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), | |
| 893 | "Shortcut+Y", REPEAT, | |
| 894 | e -> getActiveEditor().redo(), | |
| 895 | createActiveBooleanProperty( | |
| 896 | FileEditorTab::canRedoProperty ) | |
| 897 | .not() ); | |
| 898 | final Action editFindAction = new Action( Messages.get( | |
| 899 | "Main.menu.edit.find" ), "Ctrl+F", SEARCH, | |
| 900 | e -> find(), | |
| 901 | activeFileEditorIsNull ); | |
| 902 | final Action editFindNextAction = new Action( Messages.get( | |
| 903 | "Main.menu.edit.find.next" ), "F3", null, | |
| 904 | e -> findNext(), | |
| 905 | activeFileEditorIsNull ); | |
| 906 | ||
| 907 | // Insert actions | |
| 908 | final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), | |
| 909 | "Shortcut+B", BOLD, | |
| 910 | e -> getActiveEditor().surroundSelection( | |
| 911 | "**", "**" ), | |
| 912 | activeFileEditorIsNull ); | |
| 913 | final Action insertItalicAction = new Action( | |
| 914 | get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 915 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 916 | activeFileEditorIsNull ); | |
| 917 | final Action insertSuperscriptAction = new Action( get( | |
| 918 | "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT, | |
| 919 | e -> getActiveEditor().surroundSelection( | |
| 920 | "^", "^" ), | |
| 921 | activeFileEditorIsNull ); | |
| 922 | final Action insertSubscriptAction = new Action( get( | |
| 923 | "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT, | |
| 924 | e -> getActiveEditor().surroundSelection( | |
| 925 | "~", "~" ), | |
| 926 | activeFileEditorIsNull ); | |
| 927 | final Action insertStrikethroughAction = new Action( get( | |
| 928 | "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 929 | e -> getActiveEditor().surroundSelection( | |
| 930 | "~~", "~~" ), | |
| 931 | activeFileEditorIsNull ); | |
| 932 | final Action insertBlockquoteAction = new Action( get( | |
| 933 | "Main.menu.insert.blockquote" ), | |
| 934 | "Ctrl+Q", | |
| 935 | QUOTE_LEFT, | |
| 936 | // not Shortcut+Q | |
| 937 | // because of conflict | |
| 938 | // on Mac | |
| 939 | e -> getActiveEditor().surroundSelection( | |
| 940 | "\n\n> ", "" ), | |
| 941 | activeFileEditorIsNull ); | |
| 942 | final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), | |
| 943 | "Shortcut+K", CODE, | |
| 944 | e -> getActiveEditor().surroundSelection( | |
| 945 | "`", "`" ), | |
| 946 | activeFileEditorIsNull ); | |
| 947 | final Action insertFencedCodeBlockAction = new Action( get( | |
| 948 | "Main.menu.insert.fenced_code_block" ), | |
| 949 | "Shortcut+Shift+K", | |
| 950 | FILE_CODE_ALT, | |
| 951 | e -> getActiveEditor() | |
| 952 | .surroundSelection( | |
| 953 | "\n\n```\n", | |
| 954 | "\n```\n\n", | |
| 955 | get( | |
| 956 | "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 957 | activeFileEditorIsNull ); | |
| 958 | ||
| 959 | final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), | |
| 960 | "Shortcut+L", LINK, | |
| 961 | e -> getActiveEditor().insertLink(), | |
| 962 | activeFileEditorIsNull ); | |
| 963 | final Action insertImageAction = new Action( get( "Main.menu.insert" + | |
| 964 | ".image" ), | |
| 965 | "Shortcut+G", PICTURE_ALT, | |
| 966 | e -> getActiveEditor().insertImage(), | |
| 967 | activeFileEditorIsNull ); | |
| 968 | ||
| 969 | final Action[] headers = new Action[ 6 ]; | |
| 970 | ||
| 971 | // Insert header actions (H1 ... H6) | |
| 972 | for( int i = 1; i <= 6; i++ ) { | |
| 973 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 974 | final String markup = String.format( "%n%n%s ", hashes ); | |
| 975 | final String text = get( "Main.menu.insert.header_" + i ); | |
| 976 | final String accelerator = "Shortcut+" + i; | |
| 977 | final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 978 | ||
| 979 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 980 | e -> getActiveEditor().surroundSelection( | |
| 981 | markup, "", prompt ), | |
| 982 | activeFileEditorIsNull ); | |
| 983 | } | |
| 984 | ||
| 985 | final Action insertUnorderedListAction = new Action( | |
| 986 | get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 987 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 988 | activeFileEditorIsNull ); | |
| 989 | final Action insertOrderedListAction = new Action( | |
| 990 | get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 991 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 992 | activeFileEditorIsNull ); | |
| 993 | final Action insertHorizontalRuleAction = new Action( | |
| 994 | get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 995 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 996 | activeFileEditorIsNull ); | |
| 997 | ||
| 998 | // R actions | |
| 999 | final Action mRScriptAction = new Action( | |
| 1000 | get( "Main.menu.r.script" ), null, null, e -> rScript() ); | |
| 1001 | ||
| 1002 | final Action mRDirectoryAction = new Action( | |
| 1003 | get( "Main.menu.r.directory" ), null, null, e -> rDirectory() ); | |
| 1004 | ||
| 1005 | // Help actions | |
| 1006 | final Action helpAboutAction = new Action( | |
| 1007 | get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 1008 | ||
| 1009 | //---- MenuBar ---- | |
| 1010 | final Menu fileMenu = ActionUtils.createMenu( | |
| 1011 | get( "Main.menu.file" ), | |
| 1012 | fileNewAction, | |
| 1013 | fileOpenAction, | |
| 1014 | null, | |
| 1015 | fileCloseAction, | |
| 1016 | fileCloseAllAction, | |
| 1017 | null, | |
| 1018 | fileSaveAction, | |
| 1019 | fileSaveAsAction, | |
| 1020 | fileSaveAllAction, | |
| 1021 | null, | |
| 1022 | fileExitAction ); | |
| 1023 | ||
| 1024 | final Menu editMenu = ActionUtils.createMenu( | |
| 1025 | get( "Main.menu.edit" ), | |
| 1026 | editUndoAction, | |
| 1027 | editRedoAction, | |
| 1028 | editFindAction, | |
| 1029 | editFindNextAction ); | |
| 1030 | ||
| 1031 | final Menu insertMenu = ActionUtils.createMenu( | |
| 1032 | get( "Main.menu.insert" ), | |
| 1033 | insertBoldAction, | |
| 1034 | insertItalicAction, | |
| 1035 | insertSuperscriptAction, | |
| 1036 | insertSubscriptAction, | |
| 1037 | insertStrikethroughAction, | |
| 1038 | insertBlockquoteAction, | |
| 1039 | insertCodeAction, | |
| 1040 | insertFencedCodeBlockAction, | |
| 1041 | null, | |
| 1042 | insertLinkAction, | |
| 1043 | insertImageAction, | |
| 1044 | null, | |
| 1045 | headers[ 0 ], | |
| 1046 | headers[ 1 ], | |
| 1047 | headers[ 2 ], | |
| 1048 | headers[ 3 ], | |
| 1049 | headers[ 4 ], | |
| 1050 | headers[ 5 ], | |
| 1051 | null, | |
| 1052 | insertUnorderedListAction, | |
| 1053 | insertOrderedListAction, | |
| 1054 | insertHorizontalRuleAction ); | |
| 1055 | ||
| 1056 | final Menu rMenu = ActionUtils.createMenu( | |
| 1057 | get( "Main.menu.r" ), | |
| 1058 | mRScriptAction, | |
| 1059 | mRDirectoryAction ); | |
| 1060 | ||
| 1061 | final Menu helpMenu = ActionUtils.createMenu( | |
| 1062 | get( "Main.menu.help" ), | |
| 1063 | helpAboutAction ); | |
| 1064 | ||
| 1065 | menuBar = new MenuBar( fileMenu, | |
| 1066 | editMenu, | |
| 1067 | insertMenu, | |
| 1068 | rMenu, | |
| 1069 | helpMenu ); | |
| 1070 | ||
| 1071 | //---- ToolBar ---- | |
| 1072 | ToolBar toolBar = ActionUtils.createToolBar( | |
| 1073 | fileNewAction, | |
| 1074 | fileOpenAction, | |
| 1075 | fileSaveAction, | |
| 1076 | null, | |
| 1077 | editUndoAction, | |
| 1078 | editRedoAction, | |
| 1079 | null, | |
| 1080 | insertBoldAction, | |
| 1081 | insertItalicAction, | |
| 1082 | insertSuperscriptAction, | |
| 1083 | insertSubscriptAction, | |
| 1084 | insertBlockquoteAction, | |
| 1085 | insertCodeAction, | |
| 1086 | insertFencedCodeBlockAction, | |
| 1087 | null, | |
| 1088 | insertLinkAction, | |
| 1089 | insertImageAction, | |
| 1090 | null, | |
| 1091 | headers[ 0 ], | |
| 1092 | null, | |
| 1093 | insertUnorderedListAction, | |
| 1094 | insertOrderedListAction ); | |
| 1095 | ||
| 1096 | return new VBox( menuBar, toolBar ); | |
| 1097 | } | |
| 1098 | ||
| 1099 | /** | |
| 1100 | * Creates a boolean property that is bound to another boolean value of the | |
| 1101 | * active editor. | |
| 1102 | */ | |
| 1103 | private BooleanProperty createActiveBooleanProperty( | |
| 1104 | final Function<FileEditorTab, ObservableBooleanValue> func ) { | |
| 1105 | ||
| 1106 | final BooleanProperty b = new SimpleBooleanProperty(); | |
| 1107 | final FileEditorTab tab = getActiveFileEditor(); | |
| 1108 | ||
| 1109 | if( tab != null ) { | |
| 1110 | b.bind( func.apply( tab ) ); | |
| 1111 | } | |
| 1112 | ||
| 1113 | getFileEditorPane().activeFileEditorProperty().addListener( | |
| 1114 | ( observable, oldFileEditor, newFileEditor ) -> { | |
| 1115 | b.unbind(); | |
| 1116 | ||
| 1117 | if( newFileEditor != null ) { | |
| 1118 | b.bind( func.apply( newFileEditor ) ); | |
| 1119 | } | |
| 1120 | else { | |
| 1121 | b.set( false ); | |
| 1122 | } | |
| 1123 | } | |
| 1124 | ); | |
| 1125 | ||
| 1126 | return b; | |
| 1127 | } | |
| 1128 | ||
| 1129 | private void initLayout() { | |
| 1130 | final Scene appScene = getScene(); | |
| 1131 | ||
| 1132 | appScene.getStylesheets().add( STYLESHEET_SCENE ); | |
| 1133 | ||
| 1134 | // TODO: Apply an XML syntax highlighting for XML files. | |
| 1135 | // appScene.getStylesheets().add( STYLESHEET_XML ); | |
| 1136 | appScene.windowProperty().addListener( | |
| 1137 | ( observable, oldWindow, newWindow ) -> { | |
| 1138 | newWindow.setOnCloseRequest( e -> { | |
| 1139 | if( !getFileEditorPane().closeAllEditors() ) { | |
| 1140 | e.consume(); | |
| 1141 | } | |
| 1142 | } ); | |
| 1143 | ||
| 1144 | // Workaround JavaFX bug: deselect menubar if window loses focus. | |
| 1145 | newWindow.focusedProperty().addListener( | |
| 1146 | ( obs, oldFocused, newFocused ) -> { | |
| 1147 | if( !newFocused ) { | |
| 1148 | // Send an ESC key event to the menubar | |
| 1149 | this.menuBar.fireEvent( | |
| 1150 | new KeyEvent( | |
| 1151 | KEY_PRESSED, CHAR_UNDEFINED, "", ESCAPE, | |
| 1152 | false, false, false, false ) ); | |
| 1153 | } | |
| 1154 | } | |
| 1155 | ); | |
| 81 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 82 | ||
| 83 | /** | |
| 84 | * Main window containing a tab pane in the center for file editors. | |
| 85 | * | |
| 86 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 87 | */ | |
| 88 | public class MainWindow implements Observer { | |
| 89 | ||
| 90 | private final Options mOptions = Services.load( Options.class ); | |
| 91 | private final Snitch mSnitch = Services.load( Snitch.class ); | |
| 92 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 93 | ||
| 94 | private Scene scene; | |
| 95 | private StatusBar statusBar; | |
| 96 | private Text lineNumberText; | |
| 97 | private TextField findTextField; | |
| 98 | ||
| 99 | private DefinitionSource definitionSource; | |
| 100 | private DefinitionPane definitionPane; | |
| 101 | private FileEditorTabPane fileEditorPane; | |
| 102 | private HTMLPreviewPane previewPane; | |
| 103 | ||
| 104 | /** | |
| 105 | * Prevents re-instantiation of processing classes. | |
| 106 | */ | |
| 107 | private Map<FileEditorTab, Processor<String>> processors; | |
| 108 | ||
| 109 | /** | |
| 110 | * Listens on the definition pane for double-click events. | |
| 111 | */ | |
| 112 | private VariableNameInjector variableNameInjector; | |
| 113 | ||
| 114 | public MainWindow() { | |
| 115 | initLayout(); | |
| 116 | initFindInput(); | |
| 117 | initSnitch(); | |
| 118 | initDefinitionListener(); | |
| 119 | initTabAddedListener(); | |
| 120 | initTabChangedListener(); | |
| 121 | initPreferences(); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Watch for changes to external files. In particular, this awaits | |
| 126 | * modifications to any XSL files associated with XML files being edited. When | |
| 127 | * an XSL file is modified (external to the application), the snitch's ears | |
| 128 | * perk up and the file is reloaded. This keeps the XSL transformation up to | |
| 129 | * date with what's on the file system. | |
| 130 | */ | |
| 131 | private void initSnitch() { | |
| 132 | getSnitch().addObserver( this ); | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key | |
| 137 | * presses. | |
| 138 | */ | |
| 139 | private void initFindInput() { | |
| 140 | final TextField input = getFindTextField(); | |
| 141 | ||
| 142 | input.setOnKeyPressed( ( KeyEvent event ) -> { | |
| 143 | switch( event.getCode() ) { | |
| 144 | case F3: | |
| 145 | case ENTER: | |
| 146 | findNext(); | |
| 147 | break; | |
| 148 | case F: | |
| 149 | if( !event.isControlDown() ) { | |
| 150 | break; | |
| 151 | } | |
| 152 | case ESCAPE: | |
| 153 | getStatusBar().setGraphic( null ); | |
| 154 | getActiveFileEditor().getEditorPane().requestFocus(); | |
| 155 | break; | |
| 156 | } | |
| 157 | } ); | |
| 158 | ||
| 159 | // Remove when the input field loses focus. | |
| 160 | input.focusedProperty().addListener( | |
| 161 | ( | |
| 162 | final ObservableValue<? extends Boolean> focused, | |
| 163 | final Boolean oFocus, | |
| 164 | final Boolean nFocus ) -> { | |
| 165 | if( !nFocus ) { | |
| 166 | getStatusBar().setGraphic( null ); | |
| 167 | } | |
| 168 | } | |
| 169 | ); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Listen for file editor tab pane to receive an open definition source event. | |
| 174 | */ | |
| 175 | private void initDefinitionListener() { | |
| 176 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 177 | ( ObservableValue<? extends Path> definitionFile, | |
| 178 | final Path oldPath, final Path newPath ) -> { | |
| 179 | openDefinition( newPath ); | |
| 180 | ||
| 181 | // Indirectly refresh the resolved map. | |
| 182 | setProcessors( null ); | |
| 183 | updateDefinitionPane(); | |
| 184 | ||
| 185 | try { | |
| 186 | getSnitch().ignore( oldPath ); | |
| 187 | getSnitch().listen( newPath ); | |
| 188 | } catch( final IOException ex ) { | |
| 189 | error( ex ); | |
| 190 | } | |
| 191 | ||
| 192 | // Will create new processors and therefore a new resolved map. | |
| 193 | refreshSelectedTab( getActiveFileEditor() ); | |
| 194 | } | |
| 195 | ); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 200 | * that the preview pane refreshes as necessary. | |
| 201 | */ | |
| 202 | private void initTabAddedListener() { | |
| 203 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 204 | ||
| 205 | // Make sure the text processor kicks off when new files are opened. | |
| 206 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 207 | ||
| 208 | // Update the preview pane on tab changes. | |
| 209 | tabs.addListener( | |
| 210 | ( final Change<? extends Tab> change ) -> { | |
| 211 | while( change.next() ) { | |
| 212 | if( change.wasAdded() ) { | |
| 213 | // Multiple tabs can be added simultaneously. | |
| 214 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 215 | final FileEditorTab tab = (FileEditorTab) newTab; | |
| 216 | ||
| 217 | initTextChangeListener( tab ); | |
| 218 | initCaretParagraphListener( tab ); | |
| 219 | initKeyboardEventListeners( tab ); | |
| 220 | // initSyntaxListener( tab ); | |
| 221 | } | |
| 222 | } | |
| 223 | } | |
| 224 | } | |
| 225 | ); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Reloads the preferences from the previous session. | |
| 230 | */ | |
| 231 | private void initPreferences() { | |
| 232 | restoreDefinitionSource(); | |
| 233 | getFileEditorPane().restorePreferences(); | |
| 234 | updateDefinitionPane(); | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Listen for new tab selection events. | |
| 239 | */ | |
| 240 | private void initTabChangedListener() { | |
| 241 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 242 | ||
| 243 | // Update the preview pane changing tabs. | |
| 244 | editorPane.addTabSelectionListener( | |
| 245 | ( ObservableValue<? extends Tab> tabPane, | |
| 246 | final Tab oldTab, final Tab newTab ) -> { | |
| 247 | updateVariableNameInjector(); | |
| 248 | ||
| 249 | // If there was no old tab, then this is a first time load, which | |
| 250 | // can be ignored. | |
| 251 | if( oldTab != null ) { | |
| 252 | if( newTab == null ) { | |
| 253 | closeRemainingTab(); | |
| 254 | } | |
| 255 | else { | |
| 256 | // Update the preview with the edited text. | |
| 257 | refreshSelectedTab( (FileEditorTab) newTab ); | |
| 258 | } | |
| 259 | } | |
| 260 | } | |
| 261 | ); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Ensure that the keyboard events are received when a new tab is added | |
| 266 | * to the user interface. | |
| 267 | * | |
| 268 | * @param tab The tab that can trigger keyboard events, such as control+space. | |
| 269 | */ | |
| 270 | private void initKeyboardEventListeners( final FileEditorTab tab ) { | |
| 271 | final VariableNameInjector vin = getVariableNameInjector(); | |
| 272 | vin.initKeyboardEventListeners( tab ); | |
| 273 | } | |
| 274 | ||
| 275 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 276 | tab.addTextChangeListener( | |
| 277 | ( ObservableValue<? extends String> editor, | |
| 278 | final String oldValue, final String newValue ) -> | |
| 279 | refreshSelectedTab( tab ) | |
| 280 | ); | |
| 281 | } | |
| 282 | ||
| 283 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 284 | tab.addCaretParagraphListener( | |
| 285 | ( ObservableValue<? extends Integer> editor, | |
| 286 | final Integer oldValue, final Integer newValue ) -> | |
| 287 | refreshSelectedTab( tab ) | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 291 | private void updateVariableNameInjector() { | |
| 292 | getVariableNameInjector().setFileEditorTab( getActiveFileEditor() ); | |
| 293 | } | |
| 294 | ||
| 295 | private void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 296 | this.variableNameInjector = injector; | |
| 297 | } | |
| 298 | ||
| 299 | private synchronized VariableNameInjector getVariableNameInjector() { | |
| 300 | if( this.variableNameInjector == null ) { | |
| 301 | final VariableNameInjector vin = createVariableNameInjector(); | |
| 302 | setVariableNameInjector( vin ); | |
| 303 | } | |
| 304 | ||
| 305 | return this.variableNameInjector; | |
| 306 | } | |
| 307 | ||
| 308 | private VariableNameInjector createVariableNameInjector() { | |
| 309 | final FileEditorTab tab = getActiveFileEditor(); | |
| 310 | final DefinitionPane pane = getDefinitionPane(); | |
| 311 | ||
| 312 | return new VariableNameInjector( tab, pane ); | |
| 313 | } | |
| 314 | ||
| 315 | /** | |
| 316 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 317 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 318 | * or the file tab changes. | |
| 319 | * | |
| 320 | * @param tab The file editor tab that has been changed in some fashion. | |
| 321 | */ | |
| 322 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 323 | getPreviewPane().setPath( tab.getPath() ); | |
| 324 | ||
| 325 | // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29 | |
| 326 | final Position p = tab.getCaretOffset(); | |
| 327 | getLineNumberText().setText( | |
| 328 | get( STATUS_BAR_LINE, | |
| 329 | p.getMajor() + 1, | |
| 330 | p.getMinor() + 1, | |
| 331 | tab.getCaretPosition() + 1 | |
| 332 | ) | |
| 333 | ); | |
| 334 | ||
| 335 | Processor<String> processor = getProcessors().get( tab ); | |
| 336 | ||
| 337 | if( processor == null ) { | |
| 338 | processor = createProcessor( tab ); | |
| 339 | getProcessors().put( tab, processor ); | |
| 340 | } | |
| 341 | ||
| 342 | try { | |
| 343 | getNotifier().clear(); | |
| 344 | processor.processChain( tab.getEditorText() ); | |
| 345 | } catch( final Exception ex ) { | |
| 346 | error( ex ); | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * Used to find text in the active file editor window. | |
| 352 | */ | |
| 353 | private void find() { | |
| 354 | final TextField input = getFindTextField(); | |
| 355 | getStatusBar().setGraphic( input ); | |
| 356 | input.requestFocus(); | |
| 357 | } | |
| 358 | ||
| 359 | public void findNext() { | |
| 360 | getActiveFileEditor().searchNext( getFindTextField().getText() ); | |
| 361 | } | |
| 362 | ||
| 363 | /** | |
| 364 | * Returns the variable map of interpolated definitions. | |
| 365 | * | |
| 366 | * @return A map to help dereference variables. | |
| 367 | */ | |
| 368 | private Map<String, String> getResolvedMap() { | |
| 369 | return getDefinitionSource().getResolvedMap(); | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Returns the root node for the hierarchical definition source. | |
| 374 | * | |
| 375 | * @return Data to display in the definition pane. | |
| 376 | */ | |
| 377 | private TreeView<String> getTreeView() { | |
| 378 | try { | |
| 379 | return getDefinitionSource().asTreeView(); | |
| 380 | } catch( Exception e ) { | |
| 381 | error( e ); | |
| 382 | } | |
| 383 | ||
| 384 | // Slightly redundant as getDefinitionSource() might have returned an | |
| 385 | // empty definition source. | |
| 386 | return (new EmptyDefinitionSource()).asTreeView(); | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Called when a definition source is opened. | |
| 391 | * | |
| 392 | * @param path Path to the definition source that was opened. | |
| 393 | */ | |
| 394 | private void openDefinition( final Path path ) { | |
| 395 | try { | |
| 396 | final DefinitionSource ds = createDefinitionSource( path.toString() ); | |
| 397 | setDefinitionSource( ds ); | |
| 398 | storeDefinitionSource(); | |
| 399 | updateDefinitionPane(); | |
| 400 | } catch( final Exception e ) { | |
| 401 | error( e ); | |
| 402 | } | |
| 403 | } | |
| 404 | ||
| 405 | private void updateDefinitionPane() { | |
| 406 | getDefinitionPane().setRoot( getDefinitionSource().asTreeView() ); | |
| 407 | } | |
| 408 | ||
| 409 | private void restoreDefinitionSource() { | |
| 410 | final Preferences preferences = getPreferences(); | |
| 411 | final String source = preferences.get( PERSIST_DEFINITION_SOURCE, "" ); | |
| 412 | ||
| 413 | setDefinitionSource( createDefinitionSource( source ) ); | |
| 414 | } | |
| 415 | ||
| 416 | private void storeDefinitionSource() { | |
| 417 | final Preferences preferences = getPreferences(); | |
| 418 | final DefinitionSource ds = getDefinitionSource(); | |
| 419 | ||
| 420 | preferences.put( PERSIST_DEFINITION_SOURCE, ds.toString() ); | |
| 421 | } | |
| 422 | ||
| 423 | /** | |
| 424 | * Called when the last open tab is closed to clear the preview pane. | |
| 425 | */ | |
| 426 | private void closeRemainingTab() { | |
| 427 | getPreviewPane().clear(); | |
| 428 | } | |
| 429 | ||
| 430 | /** | |
| 431 | * Called when an exception occurs that warrants the user's attention. | |
| 432 | * | |
| 433 | * @param e The exception with a message that the user should know about. | |
| 434 | */ | |
| 435 | private void error( final Exception e ) { | |
| 436 | getNotifier().notify( e ); | |
| 437 | } | |
| 438 | ||
| 439 | //---- File actions ------------------------------------------------------- | |
| 440 | ||
| 441 | /** | |
| 442 | * Called when an observable instance has changed. This is called by both the | |
| 443 | * snitch service and the notify service. The snitch service can be called for | |
| 444 | * different file types, including definition sources. | |
| 445 | * | |
| 446 | * @param observable The observed instance. | |
| 447 | * @param value The noteworthy item. | |
| 448 | */ | |
| 449 | @Override | |
| 450 | public void update( final Observable observable, final Object value ) { | |
| 451 | if( value != null ) { | |
| 452 | if( observable instanceof Snitch && value instanceof Path ) { | |
| 453 | final Path path = (Path) value; | |
| 454 | final FileTypePredicate predicate | |
| 455 | = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS ); | |
| 456 | ||
| 457 | // Reload definitions. | |
| 458 | if( predicate.test( path.toFile() ) ) { | |
| 459 | updateDefinitionSource( path ); | |
| 460 | } | |
| 461 | ||
| 462 | updateSelectedTab(); | |
| 463 | } | |
| 464 | else if( observable instanceof Notifier && value instanceof String ) { | |
| 465 | updateStatusBar( (String) value ); | |
| 466 | } | |
| 467 | } | |
| 468 | } | |
| 469 | ||
| 470 | /** | |
| 471 | * Updates the status bar to show the given message. | |
| 472 | * | |
| 473 | * @param s The message to show in the status bar. | |
| 474 | */ | |
| 475 | private void updateStatusBar( final String s ) { | |
| 476 | Platform.runLater( | |
| 477 | () -> { | |
| 478 | final int index = s.indexOf( '\n' ); | |
| 479 | final String message = s.substring( | |
| 480 | 0, index > 0 ? index : s.length() ); | |
| 481 | ||
| 482 | getStatusBar().setText( message ); | |
| 483 | } | |
| 484 | ); | |
| 485 | } | |
| 486 | ||
| 487 | /** | |
| 488 | * Called when a file has been modified. | |
| 489 | */ | |
| 490 | private void updateSelectedTab() { | |
| 491 | Platform.runLater( | |
| 492 | () -> { | |
| 493 | // Brute-force XSLT file reload by re-instantiating all processors. | |
| 494 | resetProcessors(); | |
| 495 | refreshSelectedTab( getActiveFileEditor() ); | |
| 496 | } | |
| 497 | ); | |
| 498 | } | |
| 499 | ||
| 500 | /** | |
| 501 | * Reloads the definition source from the given path. | |
| 502 | * | |
| 503 | * @param path The path containing new definition information. | |
| 504 | */ | |
| 505 | private void updateDefinitionSource( final Path path ) { | |
| 506 | Platform.runLater( () -> openDefinition( path ) ); | |
| 507 | } | |
| 508 | ||
| 509 | /** | |
| 510 | * After resetting the processors, they will refresh anew to be up-to-date | |
| 511 | * with the files (text and definition) currently loaded into the editor. | |
| 512 | */ | |
| 513 | private void resetProcessors() { | |
| 514 | getProcessors().clear(); | |
| 515 | } | |
| 516 | ||
| 517 | //---- File actions ------------------------------------------------------- | |
| 518 | private void fileNew() { | |
| 519 | getFileEditorPane().newEditor(); | |
| 520 | } | |
| 521 | ||
| 522 | private void fileOpen() { | |
| 523 | getFileEditorPane().openFileDialog(); | |
| 524 | } | |
| 525 | ||
| 526 | private void fileClose() { | |
| 527 | getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | |
| 528 | } | |
| 529 | ||
| 530 | private void fileCloseAll() { | |
| 531 | getFileEditorPane().closeAllEditors(); | |
| 532 | } | |
| 533 | ||
| 534 | private void fileSave() { | |
| 535 | getFileEditorPane().saveEditor( getActiveFileEditor() ); | |
| 536 | } | |
| 537 | ||
| 538 | private void fileSaveAs() { | |
| 539 | final FileEditorTab editor = getActiveFileEditor(); | |
| 540 | getFileEditorPane().saveEditorAs( editor ); | |
| 541 | getProcessors().remove( editor ); | |
| 542 | ||
| 543 | try { | |
| 544 | refreshSelectedTab( editor ); | |
| 545 | } catch( final Exception ex ) { | |
| 546 | getNotifier().notify( ex ); | |
| 547 | } | |
| 548 | } | |
| 549 | ||
| 550 | private void fileSaveAll() { | |
| 551 | getFileEditorPane().saveAllEditors(); | |
| 552 | } | |
| 553 | ||
| 554 | private void fileExit() { | |
| 555 | final Window window = getWindow(); | |
| 556 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 557 | } | |
| 558 | ||
| 559 | //---- R menu actions | |
| 560 | private void rScript() { | |
| 561 | final String script = getPreferences().get( PERSIST_R_STARTUP, "" ); | |
| 562 | final RScriptDialog dialog = new RScriptDialog( | |
| 563 | getWindow(), "Dialog.r.script.title", script ); | |
| 564 | final Optional<String> result = dialog.showAndWait(); | |
| 565 | ||
| 566 | result.ifPresent( this::putStartupScript ); | |
| 567 | } | |
| 568 | ||
| 569 | private void rDirectory() { | |
| 570 | final TextInputDialog dialog = new TextInputDialog( | |
| 571 | getPreferences().get( PERSIST_R_DIRECTORY, USER_DIRECTORY ) | |
| 572 | ); | |
| 573 | ||
| 574 | dialog.setTitle( get( "Dialog.r.directory.title" ) ); | |
| 575 | dialog.setHeaderText( getLiteral( "Dialog.r.directory.header" ) ); | |
| 576 | dialog.setContentText( "Directory" ); | |
| 577 | ||
| 578 | final Optional<String> result = dialog.showAndWait(); | |
| 579 | ||
| 580 | result.ifPresent( this::putStartupDirectory ); | |
| 581 | } | |
| 582 | ||
| 583 | /** | |
| 584 | * Stores the R startup script into the user preferences. | |
| 585 | */ | |
| 586 | private void putStartupScript( final String script ) { | |
| 587 | putPreference( PERSIST_R_STARTUP, script ); | |
| 588 | } | |
| 589 | ||
| 590 | /** | |
| 591 | * Stores the R bootstrap script directory into the user preferences. | |
| 592 | */ | |
| 593 | private void putStartupDirectory( final String directory ) { | |
| 594 | putPreference( PERSIST_R_DIRECTORY, directory ); | |
| 595 | } | |
| 596 | ||
| 597 | //---- Help actions ------------------------------------------------------- | |
| 598 | private void helpAbout() { | |
| 599 | Alert alert = new Alert( AlertType.INFORMATION ); | |
| 600 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 601 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 602 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 603 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 604 | alert.initOwner( getWindow() ); | |
| 605 | ||
| 606 | alert.showAndWait(); | |
| 607 | } | |
| 608 | ||
| 609 | //---- Convenience accessors ---------------------------------------------- | |
| 610 | private float getFloat( final String key, final float defaultValue ) { | |
| 611 | return getPreferences().getFloat( key, defaultValue ); | |
| 612 | } | |
| 613 | ||
| 614 | private Preferences getPreferences() { | |
| 615 | return getOptions().getState(); | |
| 616 | } | |
| 617 | ||
| 618 | protected Scene getScene() { | |
| 619 | if( this.scene == null ) { | |
| 620 | this.scene = createScene(); | |
| 621 | } | |
| 622 | ||
| 623 | return this.scene; | |
| 624 | } | |
| 625 | ||
| 626 | public Window getWindow() { | |
| 627 | return getScene().getWindow(); | |
| 628 | } | |
| 629 | ||
| 630 | private MarkdownEditorPane getActiveEditor() { | |
| 631 | final EditorPane pane = getActiveFileEditor().getEditorPane(); | |
| 632 | ||
| 633 | return pane instanceof MarkdownEditorPane | |
| 634 | ? (MarkdownEditorPane) pane | |
| 635 | : null; | |
| 636 | } | |
| 637 | ||
| 638 | private FileEditorTab getActiveFileEditor() { | |
| 639 | return getFileEditorPane().getActiveFileEditor(); | |
| 640 | } | |
| 641 | ||
| 642 | //---- Member accessors --------------------------------------------------- | |
| 643 | private void setProcessors( | |
| 644 | final Map<FileEditorTab, Processor<String>> map ) { | |
| 645 | this.processors = map; | |
| 646 | } | |
| 647 | ||
| 648 | private Map<FileEditorTab, Processor<String>> getProcessors() { | |
| 649 | if( this.processors == null ) { | |
| 650 | setProcessors( new HashMap<>() ); | |
| 651 | } | |
| 652 | ||
| 653 | return this.processors; | |
| 654 | } | |
| 655 | ||
| 656 | private FileEditorTabPane getFileEditorPane() { | |
| 657 | if( this.fileEditorPane == null ) { | |
| 658 | this.fileEditorPane = createFileEditorPane(); | |
| 659 | } | |
| 660 | ||
| 661 | return this.fileEditorPane; | |
| 662 | } | |
| 663 | ||
| 664 | private HTMLPreviewPane getPreviewPane() { | |
| 665 | if( this.previewPane == null ) { | |
| 666 | this.previewPane = createPreviewPane(); | |
| 667 | } | |
| 668 | ||
| 669 | return this.previewPane; | |
| 670 | } | |
| 671 | ||
| 672 | private void setDefinitionSource( final DefinitionSource definitionSource ) { | |
| 673 | this.definitionSource = definitionSource; | |
| 674 | } | |
| 675 | ||
| 676 | private DefinitionSource getDefinitionSource() { | |
| 677 | if( this.definitionSource == null ) { | |
| 678 | this.definitionSource = new EmptyDefinitionSource(); | |
| 679 | } | |
| 680 | ||
| 681 | return this.definitionSource; | |
| 682 | } | |
| 683 | ||
| 684 | private DefinitionPane getDefinitionPane() { | |
| 685 | if( this.definitionPane == null ) { | |
| 686 | this.definitionPane = createDefinitionPane(); | |
| 687 | } | |
| 688 | ||
| 689 | return this.definitionPane; | |
| 690 | } | |
| 691 | ||
| 692 | private Options getOptions() { | |
| 693 | return mOptions; | |
| 694 | } | |
| 695 | ||
| 696 | private Snitch getSnitch() { | |
| 697 | return mSnitch; | |
| 698 | } | |
| 699 | ||
| 700 | private Notifier getNotifier() { | |
| 701 | return mNotifier; | |
| 702 | } | |
| 703 | ||
| 704 | private Text getLineNumberText() { | |
| 705 | if( this.lineNumberText == null ) { | |
| 706 | this.lineNumberText = createLineNumberText(); | |
| 707 | } | |
| 708 | ||
| 709 | return this.lineNumberText; | |
| 710 | } | |
| 711 | ||
| 712 | private synchronized StatusBar getStatusBar() { | |
| 713 | if( this.statusBar == null ) { | |
| 714 | this.statusBar = createStatusBar(); | |
| 715 | } | |
| 716 | ||
| 717 | return this.statusBar; | |
| 718 | } | |
| 719 | ||
| 720 | private TextField getFindTextField() { | |
| 721 | if( this.findTextField == null ) { | |
| 722 | this.findTextField = createFindTextField(); | |
| 723 | } | |
| 724 | ||
| 725 | return this.findTextField; | |
| 726 | } | |
| 727 | ||
| 728 | //---- Member creators ---------------------------------------------------- | |
| 729 | ||
| 730 | /** | |
| 731 | * Factory to create processors that are suited to different file types. | |
| 732 | * | |
| 733 | * @param tab The tab that is subjected to processing. | |
| 734 | * @return A processor suited to the file type specified by the tab's path. | |
| 735 | */ | |
| 736 | private Processor<String> createProcessor( final FileEditorTab tab ) { | |
| 737 | return createProcessorFactory().createProcessor( tab ); | |
| 738 | } | |
| 739 | ||
| 740 | private ProcessorFactory createProcessorFactory() { | |
| 741 | return new ProcessorFactory( getPreviewPane(), getResolvedMap() ); | |
| 742 | } | |
| 743 | ||
| 744 | private DefinitionSource createDefinitionSource( final String path ) { | |
| 745 | DefinitionSource ds; | |
| 746 | ||
| 747 | try { | |
| 748 | ds = createDefinitionFactory().createDefinitionSource( path ); | |
| 749 | ||
| 750 | if( ds instanceof FileDefinitionSource ) { | |
| 751 | try { | |
| 752 | getNotifier().notify( ds.getError() ); | |
| 753 | getSnitch().listen( ((FileDefinitionSource) ds).getPath() ); | |
| 754 | } catch( final Exception ex ) { | |
| 755 | error( ex ); | |
| 756 | } | |
| 757 | } | |
| 758 | } catch( final Exception ex ) { | |
| 759 | ds = new EmptyDefinitionSource(); | |
| 760 | error( ex ); | |
| 761 | } | |
| 762 | ||
| 763 | return ds; | |
| 764 | } | |
| 765 | ||
| 766 | private TextField createFindTextField() { | |
| 767 | return new TextField(); | |
| 768 | } | |
| 769 | ||
| 770 | /** | |
| 771 | * Create an editor pane to hold file editor tabs. | |
| 772 | * | |
| 773 | * @return A new instance, never null. | |
| 774 | */ | |
| 775 | private FileEditorTabPane createFileEditorPane() { | |
| 776 | return new FileEditorTabPane(); | |
| 777 | } | |
| 778 | ||
| 779 | private HTMLPreviewPane createPreviewPane() { | |
| 780 | return new HTMLPreviewPane(); | |
| 781 | } | |
| 782 | ||
| 783 | private DefinitionPane createDefinitionPane() { | |
| 784 | return new DefinitionPane( getTreeView() ); | |
| 785 | } | |
| 786 | ||
| 787 | private DefinitionFactory createDefinitionFactory() { | |
| 788 | return new DefinitionFactory(); | |
| 789 | } | |
| 790 | ||
| 791 | private StatusBar createStatusBar() { | |
| 792 | return new StatusBar(); | |
| 793 | } | |
| 794 | ||
| 795 | private Scene createScene() { | |
| 796 | final SplitPane splitPane = new SplitPane( | |
| 797 | getDefinitionPane().getNode(), | |
| 798 | getFileEditorPane().getNode(), | |
| 799 | getPreviewPane().getNode() ); | |
| 800 | ||
| 801 | splitPane.setDividerPositions( | |
| 802 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 803 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 804 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 805 | ||
| 806 | // See: http://broadlyapplicable.blogspot | |
| 807 | // .ca/2015/03/javafx-capture-restorePreferences-splitpane.html | |
| 808 | final BorderPane borderPane = new BorderPane(); | |
| 809 | borderPane.setPrefSize( 1024, 800 ); | |
| 810 | borderPane.setTop( createMenuBar() ); | |
| 811 | borderPane.setBottom( getStatusBar() ); | |
| 812 | borderPane.setCenter( splitPane ); | |
| 813 | ||
| 814 | final VBox box = new VBox(); | |
| 815 | box.setAlignment( Pos.BASELINE_CENTER ); | |
| 816 | box.getChildren().add( getLineNumberText() ); | |
| 817 | getStatusBar().getRightItems().add( box ); | |
| 818 | ||
| 819 | return new Scene( borderPane ); | |
| 820 | } | |
| 821 | ||
| 822 | private Text createLineNumberText() { | |
| 823 | return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) ); | |
| 824 | } | |
| 825 | ||
| 826 | private Node createMenuBar() { | |
| 827 | final BooleanBinding activeFileEditorIsNull = | |
| 828 | getFileEditorPane().activeFileEditorProperty() | |
| 829 | .isNull(); | |
| 830 | ||
| 831 | // File actions | |
| 832 | final Action fileNewAction = new Action( get( "Main.menu.file.new" ), | |
| 833 | "Shortcut+N", FILE_ALT, | |
| 834 | e -> fileNew() ); | |
| 835 | final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), | |
| 836 | "Shortcut+O", FOLDER_OPEN_ALT, | |
| 837 | e -> fileOpen() ); | |
| 838 | final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), | |
| 839 | "Shortcut+W", null, | |
| 840 | e -> fileClose(), | |
| 841 | activeFileEditorIsNull ); | |
| 842 | final Action fileCloseAllAction = new Action( get( | |
| 843 | "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), | |
| 844 | activeFileEditorIsNull ); | |
| 845 | final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), | |
| 846 | "Shortcut+S", FLOPPY_ALT, | |
| 847 | e -> fileSave(), | |
| 848 | createActiveBooleanProperty( | |
| 849 | FileEditorTab::modifiedProperty ) | |
| 850 | .not() ); | |
| 851 | final Action fileSaveAsAction = new Action( Messages.get( | |
| 852 | "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(), | |
| 853 | activeFileEditorIsNull ); | |
| 854 | final Action fileSaveAllAction = new Action( | |
| 855 | get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, | |
| 856 | e -> fileSaveAll(), | |
| 857 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 858 | final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), | |
| 859 | null, | |
| 860 | null, | |
| 861 | e -> fileExit() ); | |
| 862 | ||
| 863 | // Edit actions | |
| 864 | final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), | |
| 865 | "Shortcut+Z", UNDO, | |
| 866 | e -> getActiveEditor().undo(), | |
| 867 | createActiveBooleanProperty( | |
| 868 | FileEditorTab::canUndoProperty ) | |
| 869 | .not() ); | |
| 870 | final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), | |
| 871 | "Shortcut+Y", REPEAT, | |
| 872 | e -> getActiveEditor().redo(), | |
| 873 | createActiveBooleanProperty( | |
| 874 | FileEditorTab::canRedoProperty ) | |
| 875 | .not() ); | |
| 876 | final Action editFindAction = new Action( Messages.get( | |
| 877 | "Main.menu.edit.find" ), "Ctrl+F", SEARCH, | |
| 878 | e -> find(), | |
| 879 | activeFileEditorIsNull ); | |
| 880 | final Action editFindNextAction = new Action( Messages.get( | |
| 881 | "Main.menu.edit.find.next" ), "F3", null, | |
| 882 | e -> findNext(), | |
| 883 | activeFileEditorIsNull ); | |
| 884 | ||
| 885 | // Insert actions | |
| 886 | final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), | |
| 887 | "Shortcut+B", BOLD, | |
| 888 | e -> getActiveEditor().surroundSelection( | |
| 889 | "**", "**" ), | |
| 890 | activeFileEditorIsNull ); | |
| 891 | final Action insertItalicAction = new Action( | |
| 892 | get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 893 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 894 | activeFileEditorIsNull ); | |
| 895 | final Action insertSuperscriptAction = new Action( get( | |
| 896 | "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT, | |
| 897 | e -> getActiveEditor().surroundSelection( | |
| 898 | "^", "^" ), | |
| 899 | activeFileEditorIsNull ); | |
| 900 | final Action insertSubscriptAction = new Action( get( | |
| 901 | "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT, | |
| 902 | e -> getActiveEditor().surroundSelection( | |
| 903 | "~", "~" ), | |
| 904 | activeFileEditorIsNull ); | |
| 905 | final Action insertStrikethroughAction = new Action( get( | |
| 906 | "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 907 | e -> getActiveEditor().surroundSelection( | |
| 908 | "~~", "~~" ), | |
| 909 | activeFileEditorIsNull ); | |
| 910 | final Action insertBlockquoteAction = new Action( get( | |
| 911 | "Main.menu.insert.blockquote" ), | |
| 912 | "Ctrl+Q", | |
| 913 | QUOTE_LEFT, | |
| 914 | // not Shortcut+Q | |
| 915 | // because of conflict | |
| 916 | // on Mac | |
| 917 | e -> getActiveEditor().surroundSelection( | |
| 918 | "\n\n> ", "" ), | |
| 919 | activeFileEditorIsNull ); | |
| 920 | final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), | |
| 921 | "Shortcut+K", CODE, | |
| 922 | e -> getActiveEditor().surroundSelection( | |
| 923 | "`", "`" ), | |
| 924 | activeFileEditorIsNull ); | |
| 925 | final Action insertFencedCodeBlockAction = new Action( get( | |
| 926 | "Main.menu.insert.fenced_code_block" ), | |
| 927 | "Shortcut+Shift+K", | |
| 928 | FILE_CODE_ALT, | |
| 929 | e -> getActiveEditor() | |
| 930 | .surroundSelection( | |
| 931 | "\n\n```\n", | |
| 932 | "\n```\n\n", | |
| 933 | get( | |
| 934 | "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 935 | activeFileEditorIsNull ); | |
| 936 | ||
| 937 | final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), | |
| 938 | "Shortcut+L", LINK, | |
| 939 | e -> getActiveEditor().insertLink(), | |
| 940 | activeFileEditorIsNull ); | |
| 941 | final Action insertImageAction = new Action( get( "Main.menu.insert" + | |
| 942 | ".image" ), | |
| 943 | "Shortcut+G", PICTURE_ALT, | |
| 944 | e -> getActiveEditor().insertImage(), | |
| 945 | activeFileEditorIsNull ); | |
| 946 | ||
| 947 | final Action[] headers = new Action[ 6 ]; | |
| 948 | ||
| 949 | // Insert header actions (H1 ... H6) | |
| 950 | for( int i = 1; i <= 6; i++ ) { | |
| 951 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 952 | final String markup = String.format( "%n%n%s ", hashes ); | |
| 953 | final String text = get( "Main.menu.insert.header_" + i ); | |
| 954 | final String accelerator = "Shortcut+" + i; | |
| 955 | final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 956 | ||
| 957 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 958 | e -> getActiveEditor().surroundSelection( | |
| 959 | markup, "", prompt ), | |
| 960 | activeFileEditorIsNull ); | |
| 961 | } | |
| 962 | ||
| 963 | final Action insertUnorderedListAction = new Action( | |
| 964 | get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 965 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 966 | activeFileEditorIsNull ); | |
| 967 | final Action insertOrderedListAction = new Action( | |
| 968 | get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 969 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 970 | activeFileEditorIsNull ); | |
| 971 | final Action insertHorizontalRuleAction = new Action( | |
| 972 | get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 973 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 974 | activeFileEditorIsNull ); | |
| 975 | ||
| 976 | // R actions | |
| 977 | final Action mRScriptAction = new Action( | |
| 978 | get( "Main.menu.r.script" ), null, null, e -> rScript() ); | |
| 979 | ||
| 980 | final Action mRDirectoryAction = new Action( | |
| 981 | get( "Main.menu.r.directory" ), null, null, e -> rDirectory() ); | |
| 982 | ||
| 983 | // Help actions | |
| 984 | final Action helpAboutAction = new Action( | |
| 985 | get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 986 | ||
| 987 | //---- MenuBar ---- | |
| 988 | final Menu fileMenu = ActionUtils.createMenu( | |
| 989 | get( "Main.menu.file" ), | |
| 990 | fileNewAction, | |
| 991 | fileOpenAction, | |
| 992 | null, | |
| 993 | fileCloseAction, | |
| 994 | fileCloseAllAction, | |
| 995 | null, | |
| 996 | fileSaveAction, | |
| 997 | fileSaveAsAction, | |
| 998 | fileSaveAllAction, | |
| 999 | null, | |
| 1000 | fileExitAction ); | |
| 1001 | ||
| 1002 | final Menu editMenu = ActionUtils.createMenu( | |
| 1003 | get( "Main.menu.edit" ), | |
| 1004 | editUndoAction, | |
| 1005 | editRedoAction, | |
| 1006 | editFindAction, | |
| 1007 | editFindNextAction ); | |
| 1008 | ||
| 1009 | final Menu insertMenu = ActionUtils.createMenu( | |
| 1010 | get( "Main.menu.insert" ), | |
| 1011 | insertBoldAction, | |
| 1012 | insertItalicAction, | |
| 1013 | insertSuperscriptAction, | |
| 1014 | insertSubscriptAction, | |
| 1015 | insertStrikethroughAction, | |
| 1016 | insertBlockquoteAction, | |
| 1017 | insertCodeAction, | |
| 1018 | insertFencedCodeBlockAction, | |
| 1019 | null, | |
| 1020 | insertLinkAction, | |
| 1021 | insertImageAction, | |
| 1022 | null, | |
| 1023 | headers[ 0 ], | |
| 1024 | headers[ 1 ], | |
| 1025 | headers[ 2 ], | |
| 1026 | headers[ 3 ], | |
| 1027 | headers[ 4 ], | |
| 1028 | headers[ 5 ], | |
| 1029 | null, | |
| 1030 | insertUnorderedListAction, | |
| 1031 | insertOrderedListAction, | |
| 1032 | insertHorizontalRuleAction ); | |
| 1033 | ||
| 1034 | final Menu rMenu = ActionUtils.createMenu( | |
| 1035 | get( "Main.menu.r" ), | |
| 1036 | mRScriptAction, | |
| 1037 | mRDirectoryAction ); | |
| 1038 | ||
| 1039 | final Menu helpMenu = ActionUtils.createMenu( | |
| 1040 | get( "Main.menu.help" ), | |
| 1041 | helpAboutAction ); | |
| 1042 | ||
| 1043 | final MenuBar menuBar = new MenuBar( | |
| 1044 | fileMenu, | |
| 1045 | editMenu, | |
| 1046 | insertMenu, | |
| 1047 | rMenu, | |
| 1048 | helpMenu ); | |
| 1049 | ||
| 1050 | //---- ToolBar ---- | |
| 1051 | final ToolBar toolBar = ActionUtils.createToolBar( | |
| 1052 | fileNewAction, | |
| 1053 | fileOpenAction, | |
| 1054 | fileSaveAction, | |
| 1055 | null, | |
| 1056 | editUndoAction, | |
| 1057 | editRedoAction, | |
| 1058 | null, | |
| 1059 | insertBoldAction, | |
| 1060 | insertItalicAction, | |
| 1061 | insertSuperscriptAction, | |
| 1062 | insertSubscriptAction, | |
| 1063 | insertBlockquoteAction, | |
| 1064 | insertCodeAction, | |
| 1065 | insertFencedCodeBlockAction, | |
| 1066 | null, | |
| 1067 | insertLinkAction, | |
| 1068 | insertImageAction, | |
| 1069 | null, | |
| 1070 | headers[ 0 ], | |
| 1071 | null, | |
| 1072 | insertUnorderedListAction, | |
| 1073 | insertOrderedListAction ); | |
| 1074 | ||
| 1075 | return new VBox( menuBar, toolBar ); | |
| 1076 | } | |
| 1077 | ||
| 1078 | /** | |
| 1079 | * Creates a boolean property that is bound to another boolean value of the | |
| 1080 | * active editor. | |
| 1081 | */ | |
| 1082 | private BooleanProperty createActiveBooleanProperty( | |
| 1083 | final Function<FileEditorTab, ObservableBooleanValue> func ) { | |
| 1084 | ||
| 1085 | final BooleanProperty b = new SimpleBooleanProperty(); | |
| 1086 | final FileEditorTab tab = getActiveFileEditor(); | |
| 1087 | ||
| 1088 | if( tab != null ) { | |
| 1089 | b.bind( func.apply( tab ) ); | |
| 1090 | } | |
| 1091 | ||
| 1092 | getFileEditorPane().activeFileEditorProperty().addListener( | |
| 1093 | ( observable, oldFileEditor, newFileEditor ) -> { | |
| 1094 | b.unbind(); | |
| 1095 | ||
| 1096 | if( newFileEditor != null ) { | |
| 1097 | b.bind( func.apply( newFileEditor ) ); | |
| 1098 | } | |
| 1099 | else { | |
| 1100 | b.set( false ); | |
| 1101 | } | |
| 1102 | } | |
| 1103 | ); | |
| 1104 | ||
| 1105 | return b; | |
| 1106 | } | |
| 1107 | ||
| 1108 | private void initLayout() { | |
| 1109 | final Scene appScene = getScene(); | |
| 1110 | ||
| 1111 | appScene.getStylesheets().add( STYLESHEET_SCENE ); | |
| 1112 | ||
| 1113 | // TODO: Apply an XML syntax highlighting for XML files. | |
| 1114 | // appScene.getStylesheets().add( STYLESHEET_XML ); | |
| 1115 | appScene.windowProperty().addListener( | |
| 1116 | ( observable, oldWindow, newWindow ) -> { | |
| 1117 | newWindow.setOnCloseRequest( e -> { | |
| 1118 | if( !getFileEditorPane().closeAllEditors() ) { | |
| 1119 | e.consume(); | |
| 1120 | } | |
| 1121 | } ); | |
| 1156 | 1122 | } |
| 1157 | 1123 | ); |
| 27 | 27 | package com.scrivenvar; |
| 28 | 28 | |
| 29 | import static com.scrivenvar.Constants.APP_BUNDLE_NAME; | |
| 30 | ||
| 31 | 29 | import java.text.MessageFormat; |
| 32 | 30 | import java.util.ResourceBundle; |
| 33 | 31 | import java.util.Stack; |
| 32 | ||
| 33 | import static com.scrivenvar.Constants.APP_BUNDLE_NAME; | |
| 34 | import static java.util.ResourceBundle.getBundle; | |
| 34 | 35 | |
| 35 | 36 | /** |
| ... | ||
| 42 | 43 | |
| 43 | 44 | private static final ResourceBundle RESOURCE_BUNDLE = |
| 44 | ResourceBundle.getBundle( | |
| 45 | APP_BUNDLE_NAME ); | |
| 45 | getBundle( APP_BUNDLE_NAME ); | |
| 46 | 46 | |
| 47 | 47 | private Messages() { |
| ... | ||
| 111 | 111 | */ |
| 112 | 112 | public static String get( String key ) { |
| 113 | String result; | |
| 114 | ||
| 115 | 113 | try { |
| 116 | result = resolve( RESOURCE_BUNDLE, RESOURCE_BUNDLE.getString( key ) ); | |
| 114 | return resolve( RESOURCE_BUNDLE, RESOURCE_BUNDLE.getString( key ) ); | |
| 117 | 115 | } catch( final Exception ex ) { |
| 118 | result = key; | |
| 116 | return key; | |
| 119 | 117 | } |
| 120 | ||
| 121 | return result; | |
| 122 | 118 | } |
| 123 | 119 | |
| 89 | 89 | final FileType filetype, final Path path ) { |
| 90 | 90 | |
| 91 | DefinitionSource result = null; | |
| 92 | ||
| 93 | if( filetype == YAML ) { | |
| 94 | result = new YamlFileDefinitionSource( path ); | |
| 95 | } | |
| 96 | else { | |
| 97 | unknownFileType( filetype, path ); | |
| 98 | } | |
| 99 | ||
| 100 | return result; | |
| 91 | return filetype == YAML | |
| 92 | ? new YamlFileDefinitionSource( path ) | |
| 93 | : new EmptyDefinitionSource(); | |
| 101 | 94 | } |
| 102 | 95 |
| 29 | 29 | |
| 30 | 30 | import com.scrivenvar.AbstractPane; |
| 31 | ||
| 32 | import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR; | |
| 33 | ||
| 34 | import com.scrivenvar.predicates.strings.ContainsPredicate; | |
| 35 | import com.scrivenvar.predicates.strings.StartsPredicate; | |
| 36 | import com.scrivenvar.predicates.strings.StringPredicate; | |
| 37 | ||
| 38 | import static com.scrivenvar.util.Lists.getFirst; | |
| 39 | ||
| 40 | import java.util.List; | |
| 41 | ||
| 42 | import javafx.collections.ObservableList; | |
| 43 | import javafx.event.EventHandler; | |
| 44 | import javafx.event.EventType; | |
| 45 | import javafx.scene.Node; | |
| 46 | import javafx.scene.control.MultipleSelectionModel; | |
| 47 | import javafx.scene.control.SelectionMode; | |
| 48 | import javafx.scene.control.TreeItem; | |
| 49 | import javafx.scene.control.TreeView; | |
| 50 | import javafx.scene.input.MouseButton; | |
| 51 | ||
| 52 | import static javafx.scene.input.MouseButton.PRIMARY; | |
| 53 | ||
| 54 | import javafx.scene.input.MouseEvent; | |
| 55 | ||
| 56 | import static javafx.scene.input.MouseEvent.MOUSE_CLICKED; | |
| 57 | ||
| 58 | /** | |
| 59 | * Provides a list of variables that can be referenced in the editor. | |
| 60 | * | |
| 61 | * @author White Magic Software, Ltd. | |
| 62 | */ | |
| 63 | public class DefinitionPane extends AbstractPane { | |
| 64 | ||
| 65 | /** | |
| 66 | * Trimmed off the end of a word to match a variable name. | |
| 67 | */ | |
| 68 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; | |
| 69 | ||
| 70 | private final TreeView<String> mTreeView; | |
| 71 | ||
| 72 | /** | |
| 73 | * Constructs a definition pane with a given tree view root. | |
| 74 | * See {@link com.scrivenvar.definition.yaml.YamlTreeAdapter#adapt(String)} | |
| 75 | * for details. | |
| 76 | * | |
| 77 | * @param root The root of the variable definition tree. | |
| 78 | */ | |
| 79 | public DefinitionPane( final TreeView<String> root ) { | |
| 80 | assert root != null; | |
| 81 | ||
| 82 | mTreeView = root; | |
| 83 | initTreeView(); | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Allows observers to receive double-click events on the tree view. | |
| 88 | * | |
| 89 | * @param handler The handler that will receive double-click events. | |
| 90 | */ | |
| 91 | public void addBranchSelectedListener( | |
| 92 | final EventHandler<? super MouseEvent> handler ) { | |
| 93 | getTreeView().addEventHandler( | |
| 94 | MouseEvent.ANY, event -> { | |
| 95 | final MouseButton button = event.getButton(); | |
| 96 | final int clicks = event.getClickCount(); | |
| 97 | final EventType<? extends MouseEvent> eventType = | |
| 98 | event.getEventType(); | |
| 99 | ||
| 100 | if( PRIMARY.equals( button ) && clicks == 2 ) { | |
| 101 | if( MOUSE_CLICKED.equals( eventType ) ) { | |
| 102 | handler.handle( event ); | |
| 103 | } | |
| 104 | ||
| 105 | event.consume(); | |
| 106 | } | |
| 107 | } ); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Allows observers to stop receiving double-click events on the tree view. | |
| 112 | * | |
| 113 | * @param handler The handler that will no longer receive double-click events. | |
| 114 | */ | |
| 115 | public void removeBranchSelectedListener( | |
| 116 | final EventHandler<? super MouseEvent> handler ) { | |
| 117 | getTreeView().removeEventHandler( MouseEvent.ANY, handler ); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * Changes the root node of the tree view. Swaps the current root node for the | |
| 122 | * root node of the given | |
| 123 | * | |
| 124 | * @param treeView The tree view containing a new root node; if the parameter | |
| 125 | * is null, the tree is cleared. | |
| 126 | */ | |
| 127 | public void setRoot( final TreeView<String> treeView ) { | |
| 128 | getTreeView().setRoot( treeView == null ? null : treeView.getRoot() ); | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * Clears the tree view by setting the root node to null. | |
| 133 | */ | |
| 134 | public void clear() { | |
| 135 | setRoot( null ); | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Finds a tree item with a value that exactly matches the given word. | |
| 140 | * | |
| 141 | * @param trunk The root item containing a list of nodes to search. | |
| 142 | * @param predicate Helps determine whether the node value matches the word. | |
| 143 | * @return The item that matches the given word, or null if not found. | |
| 144 | */ | |
| 145 | private TreeItem<String> findNode( | |
| 146 | final TreeItem<String> trunk, | |
| 147 | final StringPredicate predicate ) { | |
| 148 | TreeItem<String> result = null; | |
| 149 | ||
| 150 | if( trunk != null ) { | |
| 151 | final List<TreeItem<String>> branches = trunk.getChildren(); | |
| 152 | ||
| 153 | for( final TreeItem<String> leaf : branches ) { | |
| 154 | if( predicate.test( leaf.getValue() ) ) { | |
| 155 | result = leaf; | |
| 156 | break; | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | return result; | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Calls findNode with the EqualsPredicate. See | |
| 166 | * {@link #findNode(TreeItem, StringPredicate)} for details. | |
| 167 | * | |
| 168 | * @return The result from findNode. | |
| 169 | */ | |
| 170 | private TreeItem<String> findStartsNode( | |
| 171 | final TreeItem<String> trunk, | |
| 172 | final String word ) { | |
| 173 | return findNode( trunk, new StartsPredicate( word ) ); | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * Calls findNode with the ContainsPredicate. See | |
| 178 | * {@link #findNode(TreeItem, StringPredicate)} for details. | |
| 179 | * | |
| 180 | * @return The result from findNode. | |
| 181 | */ | |
| 182 | private TreeItem<String> findSubstringNode( | |
| 183 | final TreeItem<String> trunk, | |
| 184 | final String word ) { | |
| 185 | return findNode( trunk, new ContainsPredicate( word ) ); | |
| 186 | } | |
| 187 | ||
| 188 | /** | |
| 189 | * Finds a node that matches a prefix and suffix specified by the given path | |
| 190 | * variable. The prefix must match a valid node value. The suffix refers to | |
| 191 | * the start of a string that matches zero or more children of the node | |
| 192 | * specified by the prefix. The algorithm has the following cases: | |
| 193 | * | |
| 194 | * <ol> | |
| 195 | * <li>Path is empty, return first child.</li> | |
| 196 | * <li>Path contains a complete match, return corresponding node.</li> | |
| 197 | * <li>Path contains a partial match, return nearest node.</li> | |
| 198 | * <li>Path contains a complete and partial match, return nearest node.</li> | |
| 199 | * </ol> | |
| 200 | * | |
| 201 | * @param word The word typed by the user, which contains dot-separated node | |
| 202 | * names that represent a path within the YAML tree plus a | |
| 203 | * partial variable | |
| 204 | * name match (for a node). | |
| 205 | * @return The node value that starts with the suffix portion of the given | |
| 206 | * path, never null. | |
| 207 | */ | |
| 208 | public TreeItem<String> findNode( final String word ) { | |
| 209 | String path = word; | |
| 210 | ||
| 211 | // Current tree item. | |
| 212 | TreeItem<String> cItem = getTreeRoot(); | |
| 213 | ||
| 214 | // Previous tree item. | |
| 215 | TreeItem<String> pItem = cItem; | |
| 216 | ||
| 217 | int index = path.indexOf( SEPARATOR_CHAR ); | |
| 218 | ||
| 219 | while( index >= 0 ) { | |
| 220 | final String node = path.substring( 0, index ); | |
| 221 | path = path.substring( index + 1 ); | |
| 222 | ||
| 223 | if( (cItem = findStartsNode( cItem, node )) == null ) { | |
| 224 | break; | |
| 225 | } | |
| 226 | ||
| 227 | index = path.indexOf( SEPARATOR_CHAR ); | |
| 228 | pItem = cItem; | |
| 229 | } | |
| 230 | ||
| 231 | // Find the node that starts with whatever the user typed. | |
| 232 | cItem = findStartsNode( pItem, path ); | |
| 233 | ||
| 234 | // If there was no matching node, then find a substring match. | |
| 235 | if( cItem == null ) { | |
| 236 | cItem = findSubstringNode( pItem, path ); | |
| 237 | } | |
| 238 | ||
| 239 | // If neither starts with nor substring matched a node, revert to the last | |
| 240 | // known valid node. | |
| 241 | if( cItem == null ) { | |
| 242 | cItem = pItem; | |
| 243 | } | |
| 244 | ||
| 245 | return sanitize( cItem ); | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Returns the leaf that matches the given value. If the value is terminally | |
| 250 | * punctuated, the punctuation is removed if no match was found. | |
| 251 | * | |
| 252 | * @param value The value to find, never null. | |
| 253 | * @return The leaf that contains the given value, or null if neither the | |
| 254 | * original value nor the terminally-trimmed value was found. | |
| 255 | */ | |
| 256 | public VariableTreeItem<String> findLeaf( final String value ) { | |
| 257 | return findLeaf( value, false ); | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Returns the leaf that matches the given value. If the value is terminally | |
| 262 | * punctuated, the punctuation is removed if no match was found. | |
| 263 | * | |
| 264 | * @param value The value to find, never null. | |
| 265 | * @param contains Set to true to perform a substring match if starts with | |
| 266 | * fails to match. | |
| 267 | * @return The leaf that contains the given value, or null if neither the | |
| 268 | * original value nor the terminally-trimmed value was found. | |
| 269 | */ | |
| 270 | public VariableTreeItem<String> findLeaf( | |
| 271 | final String value, | |
| 272 | final boolean contains ) { | |
| 273 | ||
| 274 | final VariableTreeItem<String> root = getTreeRoot(); | |
| 275 | final VariableTreeItem<String> leaf = root.findLeaf( value, contains ); | |
| 276 | ||
| 277 | return leaf == null | |
| 278 | ? root.findLeaf( rtrimTerminalPunctuation( value ) ) | |
| 279 | : leaf; | |
| 280 | } | |
| 281 | ||
| 282 | /** | |
| 283 | * Removes punctuation from the end of a string. The character set includes: | |
| 284 | * <code>:;,.!?-/\¡¿</code>. | |
| 285 | * | |
| 286 | * @param s The string to trim, never null. | |
| 287 | * @return The string trimmed of all terminal characters from the end | |
| 288 | */ | |
| 289 | private String rtrimTerminalPunctuation( final String s ) { | |
| 290 | final StringBuilder result = new StringBuilder( s.trim() ); | |
| 291 | ||
| 292 | while( TERMINALS.contains( "" + result.charAt( result.length() - 1 ) ) ) { | |
| 293 | result.setLength( result.length() - 1 ); | |
| 294 | } | |
| 295 | ||
| 296 | return result.toString(); | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Returns the tree root if either item or its first child are null. | |
| 301 | * | |
| 302 | * @param item The item to make null safe. | |
| 303 | * @return A non-null TreeItem, possibly the root item (to avoid null). | |
| 304 | */ | |
| 305 | private TreeItem<String> sanitize( final TreeItem<String> item ) { | |
| 306 | TreeItem<String> result; | |
| 307 | ||
| 308 | if( item == null ) { | |
| 309 | result = getTreeRoot(); | |
| 310 | } | |
| 311 | else { | |
| 312 | result = item == getTreeRoot() | |
| 313 | ? getFirst( item.getChildren() ) | |
| 314 | : item; | |
| 315 | } | |
| 316 | ||
| 317 | return result; | |
| 318 | } | |
| 319 | ||
| 320 | /** | |
| 321 | * Expands the node to the root, recursively. | |
| 322 | * | |
| 323 | * @param <T> The type of tree item to expand (usually String). | |
| 324 | * @param node The node to expand. | |
| 325 | */ | |
| 326 | public <T> void expand( final TreeItem<T> node ) { | |
| 327 | if( node != null ) { | |
| 328 | expand( node.getParent() ); | |
| 329 | ||
| 330 | if( !node.isLeaf() ) { | |
| 331 | node.setExpanded( true ); | |
| 332 | } | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | public void select( final TreeItem<String> item ) { | |
| 337 | clearSelection(); | |
| 338 | selectItem( getTreeView().getRow( item ) ); | |
| 339 | } | |
| 340 | ||
| 341 | private void clearSelection() { | |
| 342 | getSelectionModel().clearSelection(); | |
| 343 | } | |
| 344 | ||
| 345 | private void selectItem( final int row ) { | |
| 346 | getSelectionModel().select( row ); | |
| 347 | } | |
| 348 | ||
| 349 | /** | |
| 350 | * Collapses the tree, recursively. | |
| 351 | */ | |
| 352 | public void collapse() { | |
| 353 | collapse( getTreeRoot().getChildren() ); | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * Collapses the tree, recursively. | |
| 358 | * | |
| 359 | * @param <T> The type of tree item to expand (usually String). | |
| 360 | * @param nodes The nodes to collapse. | |
| 361 | */ | |
| 362 | private <T> void collapse( ObservableList<TreeItem<T>> nodes ) { | |
| 363 | for( final TreeItem<T> node : nodes ) { | |
| 364 | node.setExpanded( false ); | |
| 365 | collapse( node.getChildren() ); | |
| 366 | } | |
| 367 | } | |
| 368 | ||
| 369 | private void initTreeView() { | |
| 370 | getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE ); | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Returns the root node to the tree view. | |
| 375 | * | |
| 376 | * @return getTreeView() | |
| 377 | */ | |
| 378 | public Node getNode() { | |
| 379 | return getTreeView(); | |
| 380 | } | |
| 381 | ||
| 382 | private MultipleSelectionModel getSelectionModel() { | |
| 383 | return getTreeView().getSelectionModel(); | |
| 384 | } | |
| 385 | ||
| 386 | /** | |
| 387 | * Returns the tree view that contains the YAML definition hierarchy. | |
| 388 | * | |
| 389 | * @return A non-null instance. | |
| 390 | */ | |
| 391 | private TreeView<String> getTreeView() { | |
| 392 | return mTreeView; | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Returns the root of the tree. | |
| 397 | * | |
| 398 | * @return The first node added to the YAML definition tree. | |
| 399 | */ | |
| 400 | private VariableTreeItem<String> getTreeRoot() { | |
| 401 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 402 | ||
| 403 | return root instanceof VariableTreeItem ? | |
| 404 | (VariableTreeItem<String>) root : null; | |
| 405 | } | |
| 406 | ||
| 407 | public <T> boolean isRoot( final TreeItem<T> item ) { | |
| 408 | return getTreeRoot().equals( item ); | |
| 31 | import com.scrivenvar.predicates.strings.ContainsPredicate; | |
| 32 | import com.scrivenvar.predicates.strings.StartsPredicate; | |
| 33 | import com.scrivenvar.predicates.strings.StringPredicate; | |
| 34 | import javafx.collections.ObservableList; | |
| 35 | import javafx.event.EventHandler; | |
| 36 | import javafx.event.EventType; | |
| 37 | import javafx.scene.Node; | |
| 38 | import javafx.scene.control.MultipleSelectionModel; | |
| 39 | import javafx.scene.control.SelectionMode; | |
| 40 | import javafx.scene.control.TreeItem; | |
| 41 | import javafx.scene.control.TreeView; | |
| 42 | import javafx.scene.input.MouseButton; | |
| 43 | import javafx.scene.input.MouseEvent; | |
| 44 | ||
| 45 | import java.util.List; | |
| 46 | ||
| 47 | import static com.scrivenvar.definition.yaml.YamlParser.SEPARATOR_CHAR; | |
| 48 | import static com.scrivenvar.util.Lists.getFirst; | |
| 49 | import static javafx.scene.input.MouseButton.PRIMARY; | |
| 50 | import static javafx.scene.input.MouseEvent.MOUSE_CLICKED; | |
| 51 | ||
| 52 | /** | |
| 53 | * Provides a list of variables that can be referenced in the editor. | |
| 54 | * | |
| 55 | * @author White Magic Software, Ltd. | |
| 56 | */ | |
| 57 | public class DefinitionPane extends AbstractPane { | |
| 58 | ||
| 59 | /** | |
| 60 | * Trimmed off the end of a word to match a variable name. | |
| 61 | */ | |
| 62 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; | |
| 63 | ||
| 64 | private final TreeView<String> mTreeView; | |
| 65 | ||
| 66 | /** | |
| 67 | * Constructs a definition pane with a given tree view root. | |
| 68 | * See {@link com.scrivenvar.definition.yaml.YamlTreeAdapter#adapt(String)} | |
| 69 | * for details. | |
| 70 | * | |
| 71 | * @param root The root of the variable definition tree. | |
| 72 | */ | |
| 73 | public DefinitionPane( final TreeView<String> root ) { | |
| 74 | assert root != null; | |
| 75 | ||
| 76 | mTreeView = root; | |
| 77 | initTreeView(); | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Allows observers to receive double-click events on the tree view. | |
| 82 | * | |
| 83 | * @param handler The handler that will receive double-click events. | |
| 84 | */ | |
| 85 | public void addBranchSelectedListener( | |
| 86 | final EventHandler<? super MouseEvent> handler ) { | |
| 87 | getTreeView().addEventHandler( | |
| 88 | MouseEvent.ANY, event -> { | |
| 89 | final MouseButton button = event.getButton(); | |
| 90 | final int clicks = event.getClickCount(); | |
| 91 | final EventType<? extends MouseEvent> eventType = | |
| 92 | event.getEventType(); | |
| 93 | ||
| 94 | if( PRIMARY.equals( button ) && clicks == 2 ) { | |
| 95 | if( MOUSE_CLICKED.equals( eventType ) ) { | |
| 96 | handler.handle( event ); | |
| 97 | } | |
| 98 | ||
| 99 | event.consume(); | |
| 100 | } | |
| 101 | } ); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Changes the root node of the tree view. Swaps the current root node for the | |
| 106 | * root node of the given | |
| 107 | * | |
| 108 | * @param treeView The tree view containing a new root node; if the parameter | |
| 109 | * is null, the tree is cleared. | |
| 110 | */ | |
| 111 | public void setRoot( final TreeView<String> treeView ) { | |
| 112 | getTreeView().setRoot( treeView == null ? null : treeView.getRoot() ); | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * Finds a tree item with a value that exactly matches the given word. | |
| 117 | * | |
| 118 | * @param trunk The root item containing a list of nodes to search. | |
| 119 | * @param predicate Helps determine whether the node value matches the word. | |
| 120 | * @return The item that matches the given word, or null if not found. | |
| 121 | */ | |
| 122 | private TreeItem<String> findNode( | |
| 123 | final TreeItem<String> trunk, | |
| 124 | final StringPredicate predicate ) { | |
| 125 | TreeItem<String> result = null; | |
| 126 | ||
| 127 | if( trunk != null ) { | |
| 128 | final List<TreeItem<String>> branches = trunk.getChildren(); | |
| 129 | ||
| 130 | for( final TreeItem<String> leaf : branches ) { | |
| 131 | if( predicate.test( leaf.getValue() ) ) { | |
| 132 | result = leaf; | |
| 133 | break; | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | return result; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Calls findNode with the EqualsPredicate. See | |
| 143 | * {@link #findNode(TreeItem, StringPredicate)} for details. | |
| 144 | * | |
| 145 | * @return The result from findNode. | |
| 146 | */ | |
| 147 | private TreeItem<String> findStartsNode( | |
| 148 | final TreeItem<String> trunk, | |
| 149 | final String word ) { | |
| 150 | return findNode( trunk, new StartsPredicate( word ) ); | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Calls findNode with the ContainsPredicate. See | |
| 155 | * {@link #findNode(TreeItem, StringPredicate)} for details. | |
| 156 | * | |
| 157 | * @return The result from findNode. | |
| 158 | */ | |
| 159 | private TreeItem<String> findSubstringNode( | |
| 160 | final TreeItem<String> trunk, | |
| 161 | final String word ) { | |
| 162 | return findNode( trunk, new ContainsPredicate( word ) ); | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * Finds a node that matches a prefix and suffix specified by the given path | |
| 167 | * variable. The prefix must match a valid node value. The suffix refers to | |
| 168 | * the start of a string that matches zero or more children of the node | |
| 169 | * specified by the prefix. The algorithm has the following cases: | |
| 170 | * | |
| 171 | * <ol> | |
| 172 | * <li>Path is empty, return first child.</li> | |
| 173 | * <li>Path contains a complete match, return corresponding node.</li> | |
| 174 | * <li>Path contains a partial match, return nearest node.</li> | |
| 175 | * <li>Path contains a complete and partial match, return nearest node.</li> | |
| 176 | * </ol> | |
| 177 | * | |
| 178 | * @param word The word typed by the user, which contains dot-separated node | |
| 179 | * names that represent a path within the YAML tree plus a | |
| 180 | * partial variable | |
| 181 | * name match (for a node). | |
| 182 | * @return The node value that starts with the suffix portion of the given | |
| 183 | * path, never null. | |
| 184 | */ | |
| 185 | public TreeItem<String> findNode( final String word ) { | |
| 186 | String path = word; | |
| 187 | ||
| 188 | // Current tree item. | |
| 189 | TreeItem<String> cItem = getTreeRoot(); | |
| 190 | ||
| 191 | // Previous tree item. | |
| 192 | TreeItem<String> pItem = cItem; | |
| 193 | ||
| 194 | int index = path.indexOf( SEPARATOR_CHAR ); | |
| 195 | ||
| 196 | while( index >= 0 ) { | |
| 197 | final String node = path.substring( 0, index ); | |
| 198 | path = path.substring( index + 1 ); | |
| 199 | ||
| 200 | if( (cItem = findStartsNode( cItem, node )) == null ) { | |
| 201 | break; | |
| 202 | } | |
| 203 | ||
| 204 | index = path.indexOf( SEPARATOR_CHAR ); | |
| 205 | pItem = cItem; | |
| 206 | } | |
| 207 | ||
| 208 | // Find the node that starts with whatever the user typed. | |
| 209 | cItem = findStartsNode( pItem, path ); | |
| 210 | ||
| 211 | // If there was no matching node, then find a substring match. | |
| 212 | if( cItem == null ) { | |
| 213 | cItem = findSubstringNode( pItem, path ); | |
| 214 | } | |
| 215 | ||
| 216 | // If neither starts with nor substring matched a node, revert to the last | |
| 217 | // known valid node. | |
| 218 | if( cItem == null ) { | |
| 219 | cItem = pItem; | |
| 220 | } | |
| 221 | ||
| 222 | return sanitize( cItem ); | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Returns the leaf that matches the given value. If the value is terminally | |
| 227 | * punctuated, the punctuation is removed if no match was found. | |
| 228 | * | |
| 229 | * @param value The value to find, never null. | |
| 230 | * @param contains Set to true to perform a substring match if starts with | |
| 231 | * fails to match. | |
| 232 | * @return The leaf that contains the given value, or null if neither the | |
| 233 | * original value nor the terminally-trimmed value was found. | |
| 234 | */ | |
| 235 | public VariableTreeItem<String> findLeaf( | |
| 236 | final String value, | |
| 237 | final boolean contains ) { | |
| 238 | ||
| 239 | final VariableTreeItem<String> root = getTreeRoot(); | |
| 240 | final VariableTreeItem<String> leaf = root.findLeaf( value, contains ); | |
| 241 | ||
| 242 | return leaf == null | |
| 243 | ? root.findLeaf( rtrimTerminalPunctuation( value ) ) | |
| 244 | : leaf; | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * Removes punctuation from the end of a string. The character set includes: | |
| 249 | * <code>:;,.!?-/\¡¿</code>. | |
| 250 | * | |
| 251 | * @param s The string to trim, never null. | |
| 252 | * @return The string trimmed of all terminal characters from the end | |
| 253 | */ | |
| 254 | private String rtrimTerminalPunctuation( final String s ) { | |
| 255 | final StringBuilder result = new StringBuilder( s.trim() ); | |
| 256 | ||
| 257 | while( TERMINALS.contains( "" + result.charAt( result.length() - 1 ) ) ) { | |
| 258 | result.setLength( result.length() - 1 ); | |
| 259 | } | |
| 260 | ||
| 261 | return result.toString(); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Returns the tree root if either item or its first child are null. | |
| 266 | * | |
| 267 | * @param item The item to make null safe. | |
| 268 | * @return A non-null TreeItem, possibly the root item (to avoid null). | |
| 269 | */ | |
| 270 | private TreeItem<String> sanitize( final TreeItem<String> item ) { | |
| 271 | TreeItem<String> result; | |
| 272 | ||
| 273 | if( item == null ) { | |
| 274 | result = getTreeRoot(); | |
| 275 | } | |
| 276 | else { | |
| 277 | result = item == getTreeRoot() | |
| 278 | ? getFirst( item.getChildren() ) | |
| 279 | : item; | |
| 280 | } | |
| 281 | ||
| 282 | return result; | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * Expands the node to the root, recursively. | |
| 287 | * | |
| 288 | * @param <T> The type of tree item to expand (usually String). | |
| 289 | * @param node The node to expand. | |
| 290 | */ | |
| 291 | public <T> void expand( final TreeItem<T> node ) { | |
| 292 | if( node != null ) { | |
| 293 | expand( node.getParent() ); | |
| 294 | ||
| 295 | if( !node.isLeaf() ) { | |
| 296 | node.setExpanded( true ); | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | public void select( final TreeItem<String> item ) { | |
| 302 | clearSelection(); | |
| 303 | selectItem( getTreeView().getRow( item ) ); | |
| 304 | } | |
| 305 | ||
| 306 | private void clearSelection() { | |
| 307 | getSelectionModel().clearSelection(); | |
| 308 | } | |
| 309 | ||
| 310 | private void selectItem( final int row ) { | |
| 311 | getSelectionModel().select( row ); | |
| 312 | } | |
| 313 | ||
| 314 | /** | |
| 315 | * Collapses the tree, recursively. | |
| 316 | */ | |
| 317 | public void collapse() { | |
| 318 | collapse( getTreeRoot().getChildren() ); | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * Collapses the tree, recursively. | |
| 323 | * | |
| 324 | * @param <T> The type of tree item to expand (usually String). | |
| 325 | * @param nodes The nodes to collapse. | |
| 326 | */ | |
| 327 | private <T> void collapse( ObservableList<TreeItem<T>> nodes ) { | |
| 328 | for( final TreeItem<T> node : nodes ) { | |
| 329 | node.setExpanded( false ); | |
| 330 | collapse( node.getChildren() ); | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | private void initTreeView() { | |
| 335 | getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE ); | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Returns the root node to the tree view. | |
| 340 | * | |
| 341 | * @return getTreeView() | |
| 342 | */ | |
| 343 | public Node getNode() { | |
| 344 | return getTreeView(); | |
| 345 | } | |
| 346 | ||
| 347 | private MultipleSelectionModel<TreeItem<String>> getSelectionModel() { | |
| 348 | return getTreeView().getSelectionModel(); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Returns the tree view that contains the YAML definition hierarchy. | |
| 353 | * | |
| 354 | * @return A non-null instance. | |
| 355 | */ | |
| 356 | private TreeView<String> getTreeView() { | |
| 357 | return mTreeView; | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Returns the root of the tree. | |
| 362 | * | |
| 363 | * @return The first node added to the YAML definition tree, or a new root | |
| 364 | * if no first node could be found. | |
| 365 | */ | |
| 366 | private VariableTreeItem<String> getTreeRoot() { | |
| 367 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 368 | ||
| 369 | return root instanceof VariableTreeItem ? | |
| 370 | (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" ); | |
| 409 | 371 | } |
| 410 | 372 | } |
| 213 | 213 | for( final TreeItem<T> child : parent.getChildren() ) { |
| 214 | 214 | if( child.isLeaf() ) { |
| 215 | @SuppressWarnings( "unchecked" ) | |
| 216 | 215 | final String key = toVariable( ((VariableTreeItem<String>)child).toPath() ); |
| 217 | 216 | final String value = child.getValue().toString(); |
| 28 | 28 | package com.scrivenvar.definition.yaml; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Messages.get; | |
| 31 | 30 | import com.scrivenvar.definition.FileDefinitionSource; |
| 31 | import javafx.scene.control.TreeView; | |
| 32 | ||
| 32 | 33 | import java.io.InputStream; |
| 33 | 34 | import java.nio.file.Files; |
| 34 | 35 | import java.nio.file.Path; |
| 35 | 36 | import java.util.Map; |
| 36 | import javafx.scene.control.TreeView; | |
| 37 | ||
| 38 | import static com.scrivenvar.Messages.get; | |
| 37 | 39 | |
| 38 | 40 | /** |
| ... | ||
| 55 | 57 | init(); |
| 56 | 58 | } |
| 57 | ||
| 59 | ||
| 58 | 60 | private void init() { |
| 59 | 61 | setYamlParser( createYamlParser() ); |
| ... | ||
| 100 | 102 | protected TreeView<String> createTreeView() { |
| 101 | 103 | return getYamlTreeAdapter().adapt( |
| 102 | get( "Pane.defintion.node.root.title" ) | |
| 104 | get( "Pane.definition.node.root.title" ) | |
| 103 | 105 | ); |
| 104 | 106 | } |
| 315 | 315 | * Writes the modified YAML document to standard output. |
| 316 | 316 | */ |
| 317 | @SuppressWarnings("unused") | |
| 317 | 318 | private void writeDocument() throws IOException { |
| 318 | 319 | getObjectMapper().writeValue( System.out, getDocumentRoot() ); |
| 31 | 31 | import java.io.Writer; |
| 32 | 32 | |
| 33 | ||
| 34 | 33 | /** |
| 34 | * Responsible for producing YAML generators. | |
| 35 | 35 | * |
| 36 | 36 | * @author White Magic Software, Ltd. |
| 1 | 1 | /* |
| 2 | * The MIT License | |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | 3 | * |
| 4 | * Copyright 2017 White Magic Software, Ltd.. | |
| 4 | * All rights reserved. | |
| 5 | 5 | * |
| 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 7 | * of this software and associated documentation files (the "Software"), to deal | |
| 8 | * in the Software without restriction, including without limitation the rights | |
| 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 10 | * copies of the Software, and to permit persons to whom the Software is | |
| 11 | * furnished to do so, subject to the following conditions: | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 12 | 8 | * |
| 13 | * The above copyright notice and this permission notice shall be included in | |
| 14 | * all copies or substantial portions of the Software. | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 15 | 11 | * |
| 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
| 22 | * THE SOFTWARE. | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 23 | 27 | */ |
| 24 | 28 | package com.scrivenvar.definition.yaml.resolvers; |
| 25 | 29 | |
| 26 | import com.fasterxml.jackson.core.JsonGenerationException; | |
| 27 | 30 | import com.fasterxml.jackson.core.ObjectCodec; |
| 28 | 31 | import com.fasterxml.jackson.core.io.IOContext; |
| 29 | 32 | import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; |
| 30 | 33 | import com.scrivenvar.definition.yaml.YamlParser; |
| 34 | import org.yaml.snakeyaml.DumperOptions; | |
| 35 | ||
| 31 | 36 | import java.io.IOException; |
| 32 | 37 | import java.io.Writer; |
| 33 | import org.yaml.snakeyaml.DumperOptions; | |
| 34 | 38 | |
| 35 | /** | |
| 36 | * | |
| 37 | * @author White Magic Software, Ltd. | |
| 38 | */ | |
| 39 | 39 | /** |
| 40 | 40 | * Intercepts the string writing functionality to resolve the definition |
| 41 | 41 | * value. |
| 42 | 42 | */ |
| 43 | 43 | public class ResolverYAMLGenerator extends YAMLGenerator { |
| 44 | 44 | |
| 45 | 45 | private YamlParser yamlParser; |
| 46 | 46 | |
| 47 | 47 | public ResolverYAMLGenerator( |
| 48 | final YamlParser yamlParser, | |
| 49 | final IOContext ctxt, | |
| 50 | final int jsonFeatures, | |
| 51 | final int yamlFeatures, | |
| 52 | final ObjectCodec codec, | |
| 53 | final Writer out, | |
| 54 | final DumperOptions.Version version ) throws IOException { | |
| 48 | final YamlParser yamlParser, | |
| 49 | final IOContext ctxt, | |
| 50 | final int jsonFeatures, | |
| 51 | final int yamlFeatures, | |
| 52 | final ObjectCodec codec, | |
| 53 | final Writer out, | |
| 54 | final DumperOptions.Version version ) throws IOException { | |
| 55 | 55 | super( ctxt, jsonFeatures, yamlFeatures, codec, out, version ); |
| 56 | 56 | setYamlParser( yamlParser ); |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | @Override |
| 60 | public void writeString( final String text ) | |
| 61 | throws IOException, JsonGenerationException { | |
| 60 | public void writeString( final String text ) throws IOException { | |
| 62 | 61 | final YamlParser parser = getYamlParser(); |
| 63 | 62 | super.writeString( parser.substitute( text ) ); |
| 57 | 57 | public class EditorPane extends AbstractPane { |
| 58 | 58 | |
| 59 | private StyleClassedTextArea editor; | |
| 60 | private VirtualizedScrollPane<StyleClassedTextArea> scrollPane; | |
| 61 | private final ObjectProperty<Path> path = new SimpleObjectProperty<>(); | |
| 59 | private final StyleClassedTextArea mEditor = | |
| 60 | new StyleClassedTextArea( false ); | |
| 61 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 62 | new VirtualizedScrollPane<>( mEditor ); | |
| 63 | private final ObjectProperty<Path> mPath = new SimpleObjectProperty<>(); | |
| 62 | 64 | |
| 63 | 65 | /** |
| 64 | 66 | * Set when entering variable edit mode; retrieved upon exiting. |
| 65 | 67 | */ |
| 66 | private InputMap<InputEvent> nodeMap; | |
| 68 | private InputMap<InputEvent> mNodeMap; | |
| 69 | ||
| 70 | public EditorPane() { | |
| 71 | getScrollPane().setVbarPolicy( ScrollPane.ScrollBarPolicy.ALWAYS ); | |
| 72 | } | |
| 67 | 73 | |
| 68 | 74 | @Override |
| ... | ||
| 136 | 142 | @SuppressWarnings("unchecked") |
| 137 | 143 | public void addEventListener( final InputMap<InputEvent> map ) { |
| 138 | this.nodeMap = (InputMap<InputEvent>) getInputMap(); | |
| 144 | mNodeMap = (InputMap<InputEvent>) getInputMap(); | |
| 139 | 145 | Nodes.addInputMap( getEditor(), map ); |
| 140 | 146 | } |
| ... | ||
| 148 | 154 | public void removeEventListener( final InputMap<InputEvent> map ) { |
| 149 | 155 | Nodes.removeInputMap( getEditor(), map ); |
| 150 | Nodes.addInputMap( getEditor(), this.nodeMap ); | |
| 156 | Nodes.addInputMap( getEditor(), mNodeMap ); | |
| 151 | 157 | } |
| 152 | 158 | |
| ... | ||
| 175 | 181 | getEditor().moveTo( 0 ); |
| 176 | 182 | getScrollPane().scrollYToPixel( 0 ); |
| 177 | } | |
| 178 | ||
| 179 | private void setEditor( final StyleClassedTextArea textArea ) { | |
| 180 | this.editor = textArea; | |
| 181 | 183 | } |
| 182 | ||
| 183 | public synchronized StyleClassedTextArea getEditor() { | |
| 184 | if( this.editor == null ) { | |
| 185 | setEditor( createTextArea() ); | |
| 186 | } | |
| 187 | 184 | |
| 188 | return this.editor; | |
| 185 | public StyleClassedTextArea getEditor() { | |
| 186 | return mEditor; | |
| 189 | 187 | } |
| 190 | 188 | |
| 191 | 189 | /** |
| 192 | 190 | * Returns the scroll pane that contains the text area. |
| 193 | 191 | * |
| 194 | 192 | * @return The scroll pane that contains the content to edit. |
| 195 | 193 | */ |
| 196 | public synchronized VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 197 | if( this.scrollPane == null ) { | |
| 198 | this.scrollPane = createScrollPane(); | |
| 199 | } | |
| 200 | ||
| 201 | return this.scrollPane; | |
| 202 | } | |
| 203 | ||
| 204 | protected VirtualizedScrollPane<StyleClassedTextArea> createScrollPane() { | |
| 205 | final VirtualizedScrollPane<StyleClassedTextArea> pane | |
| 206 | = new VirtualizedScrollPane<>( getEditor() ); | |
| 207 | pane.setVbarPolicy( ScrollPane.ScrollBarPolicy.ALWAYS ); | |
| 208 | ||
| 209 | return pane; | |
| 210 | } | |
| 211 | ||
| 212 | protected StyleClassedTextArea createTextArea() { | |
| 213 | return new StyleClassedTextArea( false ); | |
| 194 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 195 | return mScrollPane; | |
| 214 | 196 | } |
| 215 | 197 | |
| 216 | 198 | public Path getPath() { |
| 217 | return this.path.get(); | |
| 199 | return mPath.get(); | |
| 218 | 200 | } |
| 219 | 201 | |
| 220 | 202 | public void setPath( final Path path ) { |
| 221 | this.path.set( path ); | |
| 203 | mPath.set( path ); | |
| 222 | 204 | } |
| 223 | 205 | } |
| 229 | 229 | |
| 230 | 230 | private Path getParentPath() { |
| 231 | final Path parentPath = getPath(); | |
| 232 | return (parentPath != null) ? parentPath.getParent() : null; | |
| 231 | final Path path = getPath(); | |
| 232 | return (path != null) ? path.getParent() : null; | |
| 233 | 233 | } |
| 234 | 234 |
| 42 | 42 | public class FileTypePredicate implements Predicate<File> { |
| 43 | 43 | |
| 44 | private final PathMatcher matcher; | |
| 44 | private final PathMatcher mMatcher; | |
| 45 | 45 | |
| 46 | 46 | /** |
| 47 | 47 | * Constructs a new instance given a set of file extension globs. |
| 48 | 48 | * |
| 49 | 49 | * @param patterns Comma-separated list of globbed extensions including the |
| 50 | 50 | * Kleene star (e.g., <code>*.md,*.markdown,*.txt</code>). |
| 51 | 51 | */ |
| 52 | 52 | public FileTypePredicate( final String patterns ) { |
| 53 | this.matcher = FileSystems.getDefault().getPathMatcher( | |
| 54 | "glob:**/{" + patterns + "}" | |
| 53 | mMatcher = FileSystems.getDefault().getPathMatcher( | |
| 54 | "glob:**{" + patterns + "}" | |
| 55 | 55 | ); |
| 56 | 56 | } |
| ... | ||
| 79 | 79 | |
| 80 | 80 | private PathMatcher getMatcher() { |
| 81 | return this.matcher; | |
| 81 | return mMatcher; | |
| 82 | 82 | } |
| 83 | 83 | } |
| 31 | 31 | import java.io.FileInputStream; |
| 32 | 32 | import java.io.FileOutputStream; |
| 33 | import java.io.IOException; | |
| 34 | import java.util.ArrayList; | |
| 35 | import java.util.Enumeration; | |
| 36 | import java.util.List; | |
| 37 | import java.util.Map; | |
| 38 | import java.util.Properties; | |
| 39 | import java.util.TreeMap; | |
| 33 | import java.util.*; | |
| 40 | 34 | import java.util.prefs.AbstractPreferences; |
| 41 | 35 | import java.util.prefs.BackingStoreException; |
| ... | ||
| 55 | 49 | private boolean mRemoved; |
| 56 | 50 | |
| 57 | public FilePreferences( final AbstractPreferences parent, final String name ) { | |
| 51 | private final Object mMutex = new Object(); | |
| 52 | ||
| 53 | public FilePreferences( final AbstractPreferences parent, | |
| 54 | final String name ) { | |
| 58 | 55 | super( parent, name ); |
| 59 | 56 | |
| ... | ||
| 132 | 129 | } |
| 133 | 130 | |
| 134 | synchronized( file ) { | |
| 131 | synchronized( mMutex ) { | |
| 135 | 132 | final Properties p = new Properties(); |
| 136 | 133 | |
| 137 | 134 | try { |
| 138 | 135 | p.load( new FileInputStream( file ) ); |
| 139 | 136 | |
| 140 | 137 | final String path = getPath(); |
| 141 | 138 | final Enumeration<?> pnen = p.propertyNames(); |
| 142 | 139 | |
| 143 | 140 | while( pnen.hasMoreElements() ) { |
| 144 | final String propKey = (String)pnen.nextElement(); | |
| 141 | final String propKey = (String) pnen.nextElement(); | |
| 145 | 142 | |
| 146 | 143 | if( propKey.startsWith( path ) ) { |
| ... | ||
| 160 | 157 | |
| 161 | 158 | private String getPath() { |
| 162 | final FilePreferences parent = (FilePreferences)parent(); | |
| 159 | final FilePreferences parent = (FilePreferences) parent(); | |
| 163 | 160 | |
| 164 | 161 | return parent == null ? "" : parent.getPath() + name() + '.'; |
| 165 | 162 | } |
| 166 | 163 | |
| 167 | 164 | @Override |
| 168 | 165 | protected void flushSpi() { |
| 169 | 166 | final File file = FilePreferencesFactory.getPreferencesFile(); |
| 170 | 167 | |
| 171 | synchronized( file ) { | |
| 168 | synchronized( mMutex ) { | |
| 172 | 169 | final Properties p = new Properties(); |
| 173 | 170 | |
| ... | ||
| 184 | 181 | |
| 185 | 182 | while( pnen.hasMoreElements() ) { |
| 186 | String propKey = (String)pnen.nextElement(); | |
| 183 | String propKey = (String) pnen.nextElement(); | |
| 187 | 184 | if( propKey.startsWith( path ) ) { |
| 188 | 185 | final String subKey = propKey.substring( path.length() ); |
| 30 | 30 | import static com.scrivenvar.Constants.CARET_POSITION_BASE; |
| 31 | 31 | import static com.scrivenvar.Constants.STYLESHEET_PREVIEW; |
| 32 | ||
| 32 | 33 | import java.nio.file.Path; |
| 34 | ||
| 33 | 35 | import javafx.beans.value.ObservableValue; |
| 34 | 36 | import javafx.concurrent.Worker.State; |
| 37 | ||
| 35 | 38 | import static javafx.concurrent.Worker.State.SUCCEEDED; |
| 39 | ||
| 36 | 40 | import javafx.scene.Node; |
| 37 | 41 | import javafx.scene.layout.Pane; |
| ... | ||
| 67 | 71 | // Scrolls to the caret after the content has been loaded. |
| 68 | 72 | getEngine().getLoadWorker().stateProperty().addListener( |
| 69 | (ObservableValue<? extends State> observable, | |
| 70 | final State oldValue, final State newValue) -> { | |
| 71 | if( newValue == SUCCEEDED ) { | |
| 72 | scrollToCaret(); | |
| 73 | } | |
| 74 | } ); | |
| 73 | ( ObservableValue<? extends State> observable, | |
| 74 | final State oldValue, final State newValue ) -> { | |
| 75 | if( newValue == SUCCEEDED ) { | |
| 76 | scrollToCaret(); | |
| 77 | } | |
| 78 | } ); | |
| 75 | 79 | } |
| 76 | 80 | |
| ... | ||
| 83 | 87 | private String getBase() { |
| 84 | 88 | final Path basePath = getPath(); |
| 89 | final Path parent = basePath == null ? null : basePath.getParent(); | |
| 85 | 90 | |
| 86 | return basePath == null | |
| 87 | ? "" | |
| 88 | : ("<base href='" + basePath.getParent().toUri().toString() + "'>"); | |
| 91 | return parent == null | |
| 92 | ? "" | |
| 93 | : ("<base href='" + parent.toUri().toString() + "'>"); | |
| 89 | 94 | } |
| 90 | 95 | |
| ... | ||
| 97 | 102 | public void update( final String html ) { |
| 98 | 103 | getEngine().loadContent( |
| 99 | "<!DOCTYPE html>" | |
| 100 | + "<html>" | |
| 101 | + "<head>" | |
| 102 | + "<link rel='stylesheet' href='" + getClass().getResource( STYLESHEET_PREVIEW ) + "'>" | |
| 103 | + getBase() | |
| 104 | + "</head>" | |
| 105 | + "<body>" | |
| 106 | + html | |
| 107 | + "</body>" | |
| 108 | + "</html>" ); | |
| 104 | "<!DOCTYPE html>" | |
| 105 | + "<html>" | |
| 106 | + "<head>" | |
| 107 | + "<link rel='stylesheet' href='" + getClass().getResource( | |
| 108 | STYLESHEET_PREVIEW ) + "'>" | |
| 109 | + getBase() | |
| 110 | + "</head>" | |
| 111 | + "<body>" | |
| 112 | + html | |
| 113 | + "</body>" | |
| 114 | + "</html>" ); | |
| 109 | 115 | } |
| 110 | 116 | |
| ... | ||
| 122 | 128 | execute( getScrollScript() ); |
| 123 | 129 | } |
| 124 | ||
| 130 | ||
| 125 | 131 | /** |
| 126 | 132 | * Returns the JavaScript used to scroll the WebView pane. |
| 127 | 133 | * |
| 128 | 134 | * @return A script that tries to center the view port on the CARET POSITION. |
| 129 | 135 | */ |
| 130 | 136 | private String getScrollScript() { |
| 131 | 137 | return "" |
| 132 | + "var e = document.getElementById('" + CARET_POSITION_BASE + "');" | |
| 133 | + "if( e != null ) { " | |
| 134 | + " Element.prototype.topOffset = function () {" | |
| 135 | + " return this.offsetTop + (this.offsetParent ? this.offsetParent.topOffset() : 0);" | |
| 136 | + " };" | |
| 137 | + " window.scrollTo( 0, e.topOffset() - (window.innerHeight / 2 ) );" | |
| 138 | + "}"; | |
| 138 | + "var e = document.getElementById('" + CARET_POSITION_BASE + "');" | |
| 139 | + "if( e != null ) { " | |
| 140 | + " Element.prototype.topOffset = function () {" | |
| 141 | + " return this.offsetTop + (this.offsetParent ? this.offsetParent" + | |
| 142 | ".topOffset() : 0);" | |
| 143 | + " };" | |
| 144 | + " window.scrollTo( 0, e.topOffset() - (window.innerHeight / 2 ) );" | |
| 145 | + "}"; | |
| 139 | 146 | } |
| 140 | 147 | |
| ... | ||
| 163 | 170 | |
| 164 | 171 | public void setPath( final Path path ) { |
| 172 | assert path != null; | |
| 173 | ||
| 165 | 174 | this.path = path; |
| 166 | 175 | } |
| 152 | 152 | |
| 153 | 153 | // Tell the user that there was a problem. |
| 154 | getNotifier().notify( get( STATUS_PARSE_ERROR, | |
| 155 | e.getMessage(), currIndex ) | |
| 154 | getNotifier().notify( | |
| 155 | get( STATUS_PARSE_ERROR, e.getMessage(), currIndex ) | |
| 156 | 156 | ); |
| 157 | 157 | } |
| 37 | 37 | import java.io.InputStreamReader; |
| 38 | 38 | import java.io.Reader; |
| 39 | import java.net.URISyntaxException; | |
| 40 | 39 | import java.net.URL; |
| 41 | 40 | import java.nio.charset.Charset; |
| ... | ||
| 56 | 55 | private PropertiesConfiguration properties; |
| 57 | 56 | |
| 58 | public DefaultSettings() | |
| 59 | throws ConfigurationException, URISyntaxException, IOException { | |
| 57 | public DefaultSettings() throws ConfigurationException { | |
| 60 | 58 | setProperties( createProperties() ); |
| 61 | 59 | } |
| 151 | 151 | # ######################################################################## |
| 152 | 152 | |
| 153 | Pane.defintion.node.root.title=Definitions | |
| 153 | Pane.definition.node.root.title=Definitions | |
| 154 | 154 | |
| 155 | 155 | # Controls ############################################################### |
| 26 | 26 | # ######################################################################## |
| 27 | 27 | # |
| 28 | # File References | |
| 28 | # File and Path References | |
| 29 | 29 | # |
| 30 | 30 | # ######################################################################## |
| ... | ||
| 43 | 43 | # Startup script for R |
| 44 | 44 | file.r.startup=/${application.package}/startup.R |
| 45 | ||
| 46 | # Default filename when a new file is created. | |
| 47 | # This ensures that the file type can always be | |
| 48 | # discerned so that the correct type of variable | |
| 49 | # reference can be inserted. | |
| 50 | file.default=untitled.md | |
| 45 | 51 | |
| 46 | 52 | # ######################################################################## |