| 23 | 23 | implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3' |
| 24 | 24 | implementation 'com.miglayout:miglayout-javafx:5.2' |
| 25 | implementation 'com.vladsch.flexmark:flexmark:0.62.0' | |
| 26 | implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.0' | |
| 27 | implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.0' | |
| 28 | implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.0' | |
| 25 | implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.6.0' | |
| 26 | implementation 'com.vladsch.flexmark:flexmark:0.62.2' | |
| 27 | implementation 'com.vladsch.flexmark:flexmark-ext-tables:0.62.2' | |
| 28 | implementation 'com.vladsch.flexmark:flexmark-ext-superscript:0.62.2' | |
| 29 | implementation 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.62.2' | |
| 29 | 30 | implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0' |
| 30 | 31 | implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.0' |
| ... | ||
| 39 | 40 | implementation 'de.jensd:fontawesomefx-commons:11.0' |
| 40 | 41 | implementation 'de.jensd:fontawesomefx-fontawesome:4.7.0-11' |
| 41 | implementation "org.renjin:renjin-script-engine:3.5-beta76" | |
| 42 | implementation 'org.renjin:renjin-script-engine:3.5-beta76' | |
| 43 | implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.20' | |
| 44 | implementation 'org.jsoup:jsoup:1.13.1' | |
| 45 | implementation 'org.apache.xmlgraphics:batik-all:1.13' | |
| 42 | 46 | |
| 43 | 47 | def os = ['win', 'linux', 'mac'] |
| 44 | def fx = ['controls', 'graphics', 'web', 'fxml'] | |
| 48 | def fx = ['controls', 'graphics', 'fxml', 'swing'] | |
| 45 | 49 | |
| 46 | 50 | fx.each { fxitem -> |
| ... | ||
| 56 | 60 | javafx { |
| 57 | 61 | version = "14" |
| 58 | modules = ['javafx.controls', 'javafx.graphics', 'javafx.web'] | |
| 62 | modules = ['javafx.controls', 'javafx.graphics', 'javafx.swing'] | |
| 59 | 63 | } |
| 60 | 64 | |
| 61 | 65 | compileJava { |
| 62 | 66 | options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" |
| 63 | 67 | } |
| 64 | 68 | |
| 65 | 69 | sourceCompatibility = JavaVersion.VERSION_11 |
| 66 | 70 | applicationName = 'scrivenvar' |
| 67 | version gitVersion() | |
| 71 | version = gitVersion() | |
| 68 | 72 | mainClassName = "com.${applicationName}.Main" |
| 69 | 73 | def launcherClassName = "com.${applicationName}.Launcher" |
| 63 | 63 | // Prevent double events when updating files on Linux (save and timestamp). |
| 64 | 64 | public static final int APP_WATCHDOG_TIMEOUT = get( |
| 65 | "application.watchdog.timeout", 100 ); | |
| 65 | "application.watchdog.timeout", 200 ); | |
| 66 | 66 | |
| 67 | 67 | public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" ); |
| ... | ||
| 76 | 76 | public static final String FILE_LOGO_256 = get( "file.logo.256" ); |
| 77 | 77 | public static final String FILE_LOGO_512 = get( "file.logo.512" ); |
| 78 | ||
| 79 | public static final String CARET_POSITION_BASE = get( "caret.token.base" ); | |
| 80 | public static final String CARET_POSITION_MD = get( "caret.token.markdown" ); | |
| 81 | public static final String CARET_POSITION_HTML = get( "caret.token.html" ); | |
| 82 | 78 | |
| 83 | 79 | public static final String PREFS_ROOT = get( "preferences.root" ); |
| 84 | 80 | public static final String PREFS_STATE = get( "preferences.root.state" ); |
| 85 | public static final String PREFS_OPTIONS = get( "preferences.root.options" ); | |
| 86 | 81 | |
| 87 | 82 | // Refer to filename extension settings in the configuration file. Do not |
| ... | ||
| 106 | 101 | */ |
| 107 | 102 | public static final int DEFAULT_MAP_SIZE = 64; |
| 108 | ||
| 109 | /** | |
| 110 | * Location of the definition source file. | |
| 111 | */ | |
| 112 | public static final String PERSIST_DEFINITION_SOURCE = "definitionSource"; | |
| 113 | ||
| 114 | /** | |
| 115 | * Content of the R startup script. | |
| 116 | */ | |
| 117 | public static final String PERSIST_R_STARTUP = "rStartup"; | |
| 118 | 103 | |
| 119 | /** | |
| 120 | * Bootstrap directory for R startup script. | |
| 121 | */ | |
| 122 | public static final String PERSIST_R_DIRECTORY = "rDirectory"; | |
| 104 | public static final String PERSIST_IMAGES_DEFAULT = | |
| 105 | get( "file.stylesheet.scene" ); | |
| 123 | 106 | |
| 124 | 107 | /** |
| 352 | 352 | */ |
| 353 | 353 | private Charset detectEncoding( final byte[] bytes ) { |
| 354 | final UniversalDetector detector = new UniversalDetector( null ); | |
| 354 | final var detector = new UniversalDetector( null ); | |
| 355 | 355 | detector.handleData( bytes, 0, bytes.length ); |
| 356 | 356 | detector.dataEnd(); |
| 357 | 357 | |
| 358 | 358 | final String charset = detector.getDetectedCharset(); |
| 359 | final Charset charEncoding = charset == null | |
| 359 | ||
| 360 | return charset == null | |
| 360 | 361 | ? Charset.defaultCharset() |
| 361 | 362 | : Charset.forName( charset.toUpperCase( ENGLISH ) ); |
| 362 | ||
| 363 | detector.reset(); | |
| 364 | ||
| 365 | return charEncoding; | |
| 366 | 363 | } |
| 367 | 364 |
| 58 | 58 | import java.util.Optional; |
| 59 | 59 | import java.util.concurrent.atomic.AtomicReference; |
| 60 | import java.util.prefs.Preferences; | |
| 61 | import java.util.stream.Collectors; | |
| 62 | ||
| 63 | import static com.scrivenvar.Constants.GLOB_PREFIX_FILE; | |
| 64 | import static com.scrivenvar.FileType.*; | |
| 65 | import static com.scrivenvar.Messages.get; | |
| 66 | import static com.scrivenvar.service.events.Notifier.YES; | |
| 67 | ||
| 68 | /** | |
| 69 | * Tab pane for file editors. | |
| 70 | * | |
| 71 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 72 | */ | |
| 73 | public final class FileEditorTabPane extends TabPane { | |
| 74 | ||
| 75 | private final static String FILTER_EXTENSION_TITLES = | |
| 76 | "Dialog.file.choose.filter"; | |
| 77 | ||
| 78 | private final Options mOptions = Services.load( Options.class ); | |
| 79 | private final Settings mSettings = Services.load( Settings.class ); | |
| 80 | private final Notifier mNotifyService = Services.load( Notifier.class ); | |
| 81 | ||
| 82 | private final ReadOnlyObjectWrapper<Path> openDefinition = | |
| 83 | new ReadOnlyObjectWrapper<>(); | |
| 84 | private final ReadOnlyObjectWrapper<FileEditorTab> mActiveFileEditor = | |
| 85 | new ReadOnlyObjectWrapper<>(); | |
| 86 | private final ReadOnlyBooleanWrapper anyFileEditorModified = | |
| 87 | new ReadOnlyBooleanWrapper(); | |
| 88 | ||
| 89 | /** | |
| 90 | * Constructs a new file editor tab pane. | |
| 91 | */ | |
| 92 | public FileEditorTabPane() { | |
| 93 | final ObservableList<Tab> tabs = getTabs(); | |
| 94 | ||
| 95 | setFocusTraversable( false ); | |
| 96 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 97 | ||
| 98 | addTabSelectionListener( | |
| 99 | ( ObservableValue<? extends Tab> tabPane, | |
| 100 | final Tab oldTab, final Tab newTab ) -> { | |
| 101 | ||
| 102 | if( newTab != null ) { | |
| 103 | mActiveFileEditor.set( (FileEditorTab) newTab ); | |
| 104 | } | |
| 105 | } | |
| 106 | ); | |
| 107 | ||
| 108 | final ChangeListener<Boolean> modifiedListener = ( observable, oldValue, | |
| 109 | newValue ) -> { | |
| 110 | for( final Tab tab : tabs ) { | |
| 111 | if( ((FileEditorTab) tab).isModified() ) { | |
| 112 | this.anyFileEditorModified.set( true ); | |
| 113 | break; | |
| 114 | } | |
| 115 | } | |
| 116 | }; | |
| 117 | ||
| 118 | tabs.addListener( | |
| 119 | (ListChangeListener<Tab>) change -> { | |
| 120 | while( change.next() ) { | |
| 121 | if( change.wasAdded() ) { | |
| 122 | change.getAddedSubList().forEach( | |
| 123 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 124 | .addListener( modifiedListener ) ); | |
| 125 | } | |
| 126 | else if( change.wasRemoved() ) { | |
| 127 | change.getRemoved().forEach( | |
| 128 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 129 | .removeListener( | |
| 130 | modifiedListener ) ); | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | // Changes in the tabs may also change anyFileEditorModified property | |
| 135 | // (e.g. closed modified file) | |
| 136 | modifiedListener.changed( null, null, null ); | |
| 137 | } | |
| 138 | ); | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Allows observers to be notified when the current file editor tab changes. | |
| 143 | * | |
| 144 | * @param listener The listener to notify of tab change events. | |
| 145 | */ | |
| 146 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 147 | // Observe the tab so that when a new tab is opened or selected, | |
| 148 | // a notification is kicked off. | |
| 149 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Returns the tab that has keyboard focus. | |
| 154 | * | |
| 155 | * @return A non-null instance. | |
| 156 | */ | |
| 157 | public FileEditorTab getActiveFileEditor() { | |
| 158 | return mActiveFileEditor.get(); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Returns the property corresponding to the tab that has focus. | |
| 163 | * | |
| 164 | * @return A non-null instance. | |
| 165 | */ | |
| 166 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 167 | return mActiveFileEditor.getReadOnlyProperty(); | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Property that can answer whether the text has been modified. | |
| 172 | * | |
| 173 | * @return A non-null instance, true meaning the content has not been saved. | |
| 174 | */ | |
| 175 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 176 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * Creates a new editor instance from the given path. | |
| 181 | * | |
| 182 | * @param path The file to open. | |
| 183 | * @return A non-null instance. | |
| 184 | */ | |
| 185 | private FileEditorTab createFileEditor( final Path path ) { | |
| 186 | assert path != null; | |
| 187 | ||
| 188 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 189 | ||
| 190 | tab.setOnCloseRequest( e -> { | |
| 191 | if( !canCloseEditor( tab ) ) { | |
| 192 | e.consume(); | |
| 193 | } | |
| 194 | else if( isActiveFileEditor( tab ) ) { | |
| 195 | // Prevent prompting the user to save when there are no file editor | |
| 196 | // tabs open. | |
| 197 | mActiveFileEditor.set( null ); | |
| 198 | } | |
| 199 | } ); | |
| 200 | ||
| 201 | return tab; | |
| 202 | } | |
| 203 | ||
| 204 | private boolean isActiveFileEditor( final FileEditorTab tab ) { | |
| 205 | return getActiveFileEditor() == tab; | |
| 206 | } | |
| 207 | ||
| 208 | private Path getDefaultPath() { | |
| 209 | final String filename = getDefaultFilename(); | |
| 210 | return (new File( filename )).toPath(); | |
| 211 | } | |
| 212 | ||
| 213 | private String getDefaultFilename() { | |
| 214 | return getSettings().getSetting( "file.default", "untitled.md" ); | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * Called when the user selects New from the File menu. | |
| 219 | */ | |
| 220 | void newEditor() { | |
| 221 | final Path defaultPath = getDefaultPath(); | |
| 222 | final FileEditorTab tab = createFileEditor( defaultPath ); | |
| 223 | ||
| 224 | getTabs().add( tab ); | |
| 225 | getSelectionModel().select( tab ); | |
| 226 | } | |
| 227 | ||
| 228 | void openFileDialog() { | |
| 229 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 230 | final FileChooser dialog = createFileChooser( title ); | |
| 231 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 232 | ||
| 233 | if( files != null ) { | |
| 234 | openFiles( files ); | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | /** | |
| 239 | * Opens the files into new editors, unless one of those files was a | |
| 240 | * definition file. The definition file is loaded into the definition pane, | |
| 241 | * but only the first one selected (multiple definition files will result in a | |
| 242 | * warning). | |
| 243 | * | |
| 244 | * @param files The list of non-definition files that the were requested to | |
| 245 | * open. | |
| 246 | */ | |
| 247 | private void openFiles( final List<File> files ) { | |
| 248 | final List<String> extensions = | |
| 249 | createExtensionFilter( DEFINITION ).getExtensions(); | |
| 250 | final FileTypePredicate predicate = | |
| 251 | new FileTypePredicate( extensions ); | |
| 252 | ||
| 253 | // The user might have opened multiple definitions files. These will | |
| 254 | // be discarded from the text editable files. | |
| 255 | final List<File> definitions | |
| 256 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 257 | ||
| 258 | // Create a modifiable list to remove any definition files that were | |
| 259 | // opened. | |
| 260 | final List<File> editors = new ArrayList<>( files ); | |
| 261 | ||
| 262 | if( !editors.isEmpty() ) { | |
| 263 | saveLastDirectory( editors.get( 0 ) ); | |
| 264 | } | |
| 265 | ||
| 266 | editors.removeAll( definitions ); | |
| 267 | ||
| 268 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 269 | if( !editors.isEmpty() ) { | |
| 270 | openEditors( editors, 0 ); | |
| 271 | } | |
| 272 | ||
| 273 | if( !definitions.isEmpty() ) { | |
| 274 | openDefinition( definitions.get( 0 ) ); | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 279 | final int fileTally = files.size(); | |
| 280 | final List<Tab> tabs = getTabs(); | |
| 281 | ||
| 282 | // Close single unmodified "Untitled" tab. | |
| 283 | if( tabs.size() == 1 ) { | |
| 284 | final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 )); | |
| 285 | ||
| 286 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 287 | closeEditor( fileEditor, false ); | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | for( int i = 0; i < fileTally; i++ ) { | |
| 292 | final Path path = files.get( i ).toPath(); | |
| 293 | ||
| 294 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 295 | ||
| 296 | // Only open new files. | |
| 297 | if( fileEditorTab == null ) { | |
| 298 | fileEditorTab = createFileEditor( path ); | |
| 299 | getTabs().add( fileEditorTab ); | |
| 300 | } | |
| 301 | ||
| 302 | // Select the first file in the list. | |
| 303 | if( i == activeIndex ) { | |
| 304 | getSelectionModel().select( fileEditorTab ); | |
| 305 | } | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Returns a property that changes when a new definition file is opened. | |
| 311 | * | |
| 312 | * @return The path to a definition file that was opened. | |
| 313 | */ | |
| 314 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 315 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 316 | } | |
| 317 | ||
| 318 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 319 | return this.openDefinition; | |
| 320 | } | |
| 321 | ||
| 322 | /** | |
| 323 | * Called when the user has opened a definition file (using the file open | |
| 324 | * dialog box). This will replace the current set of definitions for the | |
| 325 | * active tab. | |
| 326 | * | |
| 327 | * @param definition The file to open. | |
| 328 | */ | |
| 329 | private void openDefinition( final File definition ) { | |
| 330 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 331 | // (might be a matter of checking the value first). | |
| 332 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Called when the contents of the editor are to be saved. | |
| 337 | * | |
| 338 | * @param tab The tab containing content to save. | |
| 339 | * @return true The contents were saved (or needn't be saved). | |
| 340 | */ | |
| 341 | public boolean saveEditor( final FileEditorTab tab ) { | |
| 342 | if( tab == null || !tab.isModified() ) { | |
| 343 | return true; | |
| 344 | } | |
| 345 | ||
| 346 | return tab.getPath() == null ? saveEditorAs( tab ) : tab.save(); | |
| 347 | } | |
| 348 | ||
| 349 | /** | |
| 350 | * Opens the Save As dialog for the user to save the content under a new | |
| 351 | * path. | |
| 352 | * | |
| 353 | * @param tab The tab with contents to save. | |
| 354 | * @return true The contents were saved, or the tab was null. | |
| 355 | */ | |
| 356 | public boolean saveEditorAs( final FileEditorTab tab ) { | |
| 357 | if( tab == null ) { | |
| 358 | return true; | |
| 359 | } | |
| 360 | ||
| 361 | getSelectionModel().select( tab ); | |
| 362 | ||
| 363 | final FileChooser fileChooser = createFileChooser( get( | |
| 364 | "Dialog.file.choose.save.title" ) ); | |
| 365 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 366 | if( file == null ) { | |
| 367 | return false; | |
| 368 | } | |
| 369 | ||
| 370 | saveLastDirectory( file ); | |
| 371 | tab.setPath( file.toPath() ); | |
| 372 | ||
| 373 | return tab.save(); | |
| 374 | } | |
| 375 | ||
| 376 | void saveAllEditors() { | |
| 377 | for( final FileEditorTab fileEditor : getAllEditors() ) { | |
| 378 | saveEditor( fileEditor ); | |
| 379 | } | |
| 380 | } | |
| 381 | ||
| 382 | /** | |
| 383 | * Answers whether the file has had modifications. ' | |
| 384 | * | |
| 385 | * @param tab THe tab to check for modifications. | |
| 386 | * @return false The file is unmodified. | |
| 387 | */ | |
| 388 | @SuppressWarnings("BooleanMethodIsAlwaysInverted") | |
| 389 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 390 | final AtomicReference<Boolean> canClose = new AtomicReference<>(); | |
| 391 | canClose.set( true ); | |
| 392 | ||
| 393 | if( tab.isModified() ) { | |
| 394 | final Notification message = getNotifyService().createNotification( | |
| 395 | Messages.get( "Alert.file.close.title" ), | |
| 396 | Messages.get( "Alert.file.close.text" ), | |
| 397 | tab.getText() | |
| 398 | ); | |
| 399 | ||
| 400 | final Alert confirmSave = getNotifyService().createConfirmation( | |
| 401 | getWindow(), message ); | |
| 402 | ||
| 403 | final Optional<ButtonType> buttonType = confirmSave.showAndWait(); | |
| 404 | ||
| 405 | buttonType.ifPresent( | |
| 406 | save -> canClose.set( | |
| 407 | save == YES ? saveEditor( tab ) : save == ButtonType.NO | |
| 408 | ) | |
| 409 | ); | |
| 410 | } | |
| 411 | ||
| 412 | return canClose.get(); | |
| 413 | } | |
| 414 | ||
| 415 | private Notifier getNotifyService() { | |
| 416 | return this.mNotifyService; | |
| 417 | } | |
| 418 | ||
| 419 | boolean closeEditor( final FileEditorTab tab, final boolean save ) { | |
| 420 | if( tab == null ) { | |
| 421 | return true; | |
| 422 | } | |
| 423 | ||
| 424 | if( save ) { | |
| 425 | Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | |
| 426 | Event.fireEvent( tab, event ); | |
| 427 | ||
| 428 | if( event.isConsumed() ) { | |
| 429 | return false; | |
| 430 | } | |
| 431 | } | |
| 432 | ||
| 433 | getTabs().remove( tab ); | |
| 434 | ||
| 435 | if( tab.getOnClosed() != null ) { | |
| 436 | Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | |
| 437 | } | |
| 438 | ||
| 439 | return true; | |
| 440 | } | |
| 441 | ||
| 442 | boolean closeAllEditors() { | |
| 443 | final FileEditorTab[] allEditors = getAllEditors(); | |
| 444 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 445 | ||
| 446 | // try to save active tab first because in case the user decides to cancel, | |
| 447 | // then it stays active | |
| 448 | if( activeEditor != null && !canCloseEditor( activeEditor ) ) { | |
| 449 | return false; | |
| 450 | } | |
| 451 | ||
| 452 | // This should be called any time a tab changes. | |
| 453 | persistPreferences(); | |
| 454 | ||
| 455 | // save modified tabs | |
| 456 | for( int i = 0; i < allEditors.length; i++ ) { | |
| 457 | final FileEditorTab fileEditor = allEditors[ i ]; | |
| 458 | ||
| 459 | if( fileEditor == activeEditor ) { | |
| 460 | continue; | |
| 461 | } | |
| 462 | ||
| 463 | if( fileEditor.isModified() ) { | |
| 464 | // activate the modified tab to make its modified content visible to | |
| 465 | // the user | |
| 466 | getSelectionModel().select( i ); | |
| 467 | ||
| 468 | if( !canCloseEditor( fileEditor ) ) { | |
| 469 | return false; | |
| 470 | } | |
| 471 | } | |
| 472 | } | |
| 473 | ||
| 474 | // Close all tabs. | |
| 475 | for( final FileEditorTab fileEditor : allEditors ) { | |
| 476 | if( !closeEditor( fileEditor, false ) ) { | |
| 477 | return false; | |
| 478 | } | |
| 479 | } | |
| 480 | ||
| 481 | return getTabs().isEmpty(); | |
| 482 | } | |
| 483 | ||
| 484 | private FileEditorTab[] getAllEditors() { | |
| 485 | final ObservableList<Tab> tabs = getTabs(); | |
| 486 | final int length = tabs.size(); | |
| 487 | final FileEditorTab[] allEditors = new FileEditorTab[ length ]; | |
| 488 | ||
| 489 | for( int i = 0; i < length; i++ ) { | |
| 490 | allEditors[ i ] = (FileEditorTab) tabs.get( i ); | |
| 491 | } | |
| 492 | ||
| 493 | return allEditors; | |
| 494 | } | |
| 495 | ||
| 496 | /** | |
| 497 | * Returns the file editor tab that has the given path. | |
| 498 | * | |
| 499 | * @return null No file editor tab for the given path was found. | |
| 500 | */ | |
| 501 | private FileEditorTab findEditor( final Path path ) { | |
| 502 | for( final Tab tab : getTabs() ) { | |
| 503 | final FileEditorTab fileEditor = (FileEditorTab) tab; | |
| 504 | ||
| 505 | if( fileEditor.isPath( path ) ) { | |
| 506 | return fileEditor; | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | return null; | |
| 511 | } | |
| 512 | ||
| 513 | private FileChooser createFileChooser( String title ) { | |
| 514 | final FileChooser fileChooser = new FileChooser(); | |
| 515 | ||
| 516 | fileChooser.setTitle( title ); | |
| 517 | fileChooser.getExtensionFilters().addAll( | |
| 518 | createExtensionFilters() ); | |
| 519 | ||
| 520 | final String lastDirectory = getPreferences().get( "lastDirectory", null ); | |
| 521 | File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | |
| 522 | ||
| 523 | if( !file.isDirectory() ) { | |
| 524 | file = new File( "." ); | |
| 525 | } | |
| 526 | ||
| 527 | fileChooser.setInitialDirectory( file ); | |
| 528 | return fileChooser; | |
| 529 | } | |
| 530 | ||
| 531 | private List<ExtensionFilter> createExtensionFilters() { | |
| 532 | final List<ExtensionFilter> list = new ArrayList<>(); | |
| 533 | ||
| 534 | // TODO: Return a list of all properties that match the filter prefix. | |
| 535 | // This will allow dynamic filters to be added and removed just by | |
| 536 | // updating the properties file. | |
| 537 | list.add( createExtensionFilter( ALL ) ); | |
| 538 | list.add( createExtensionFilter( SOURCE ) ); | |
| 539 | list.add( createExtensionFilter( DEFINITION ) ); | |
| 540 | list.add( createExtensionFilter( XML ) ); | |
| 541 | return list; | |
| 542 | } | |
| 543 | ||
| 544 | /** | |
| 545 | * Returns a filter for file name extensions recognized by the application | |
| 546 | * that can be opened by the user. | |
| 547 | * | |
| 548 | * @param filetype Used to find the globbing pattern for extensions. | |
| 549 | * @return A filename filter suitable for use by a FileDialog instance. | |
| 550 | */ | |
| 551 | private ExtensionFilter createExtensionFilter( final FileType filetype ) { | |
| 552 | final String tKey = String.format( "%s.title.%s", | |
| 553 | FILTER_EXTENSION_TITLES, | |
| 554 | filetype ); | |
| 555 | final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype ); | |
| 556 | ||
| 557 | return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | |
| 558 | } | |
| 559 | ||
| 560 | private List<String> getExtensions( final String key ) { | |
| 561 | return getSettings().getStringSettingList( key ); | |
| 562 | } | |
| 563 | ||
| 564 | private void saveLastDirectory( final File file ) { | |
| 565 | getPreferences().put( "lastDirectory", file.getParent() ); | |
| 566 | } | |
| 567 | ||
| 568 | public void restorePreferences() { | |
| 569 | int activeIndex = 0; | |
| 570 | ||
| 571 | final Preferences preferences = getPreferences(); | |
| 572 | final String[] fileNames = Utils.getPrefsStrings( preferences, "file" ); | |
| 573 | final String activeFileName = preferences.get( "activeFile", null ); | |
| 574 | ||
| 575 | final ArrayList<File> files = new ArrayList<>( fileNames.length ); | |
| 576 | ||
| 577 | for( final String fileName : fileNames ) { | |
| 578 | final File file = new File( fileName ); | |
| 579 | ||
| 580 | if( file.exists() ) { | |
| 581 | files.add( file ); | |
| 582 | ||
| 583 | if( fileName.equals( activeFileName ) ) { | |
| 584 | activeIndex = files.size() - 1; | |
| 585 | } | |
| 586 | } | |
| 587 | } | |
| 588 | ||
| 589 | if( files.isEmpty() ) { | |
| 590 | newEditor(); | |
| 591 | } | |
| 592 | else { | |
| 593 | openEditors( files, activeIndex ); | |
| 594 | } | |
| 595 | } | |
| 596 | ||
| 597 | public void persistPreferences() { | |
| 598 | final ObservableList<Tab> allEditors = getTabs(); | |
| 599 | final List<String> fileNames = new ArrayList<>( allEditors.size() ); | |
| 600 | ||
| 601 | for( final Tab tab : allEditors ) { | |
| 602 | final FileEditorTab fileEditor = (FileEditorTab) tab; | |
| 603 | final Path filePath = fileEditor.getPath(); | |
| 604 | ||
| 605 | if( filePath != null ) { | |
| 606 | fileNames.add( filePath.toString() ); | |
| 607 | } | |
| 608 | } | |
| 609 | ||
| 610 | final Preferences preferences = getPreferences(); | |
| 611 | Utils.putPrefsStrings( preferences, | |
| 612 | "file", | |
| 613 | fileNames.toArray( new String[ 0 ] ) ); | |
| 614 | ||
| 615 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 616 | final Path filePath = activeEditor == null ? null : activeEditor.getPath(); | |
| 617 | ||
| 618 | if( filePath == null ) { | |
| 619 | preferences.remove( "activeFile" ); | |
| 620 | } | |
| 621 | else { | |
| 622 | preferences.put( "activeFile", filePath.toString() ); | |
| 623 | } | |
| 624 | } | |
| 625 | ||
| 626 | private Settings getSettings() { | |
| 627 | return mSettings; | |
| 628 | } | |
| 629 | ||
| 630 | protected Options getOptions() { | |
| 631 | return mOptions; | |
| 60 | import java.util.function.Consumer; | |
| 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 = | |
| 77 | "Dialog.file.choose.filter"; | |
| 78 | ||
| 79 | private final static Options sOptions = Services.load( Options.class ); | |
| 80 | private final static Settings sSettings = Services.load( Settings.class ); | |
| 81 | private final static Notifier sNotifier = 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 | private final Consumer<Double> mScrollEventObserver; | |
| 90 | ||
| 91 | /** | |
| 92 | * Constructs a new file editor tab pane. | |
| 93 | */ | |
| 94 | public FileEditorTabPane( final Consumer<Double> scrollEventObserver ) { | |
| 95 | final ObservableList<Tab> tabs = getTabs(); | |
| 96 | ||
| 97 | setFocusTraversable( false ); | |
| 98 | setTabClosingPolicy( TabClosingPolicy.ALL_TABS ); | |
| 99 | ||
| 100 | addTabSelectionListener( | |
| 101 | ( ObservableValue<? extends Tab> tabPane, | |
| 102 | final Tab oldTab, final Tab newTab ) -> { | |
| 103 | ||
| 104 | if( newTab != null ) { | |
| 105 | mActiveFileEditor.set( (FileEditorTab) newTab ); | |
| 106 | } | |
| 107 | } | |
| 108 | ); | |
| 109 | ||
| 110 | final ChangeListener<Boolean> modifiedListener = ( observable, oldValue, | |
| 111 | newValue ) -> { | |
| 112 | for( final Tab tab : tabs ) { | |
| 113 | if( ((FileEditorTab) tab).isModified() ) { | |
| 114 | this.anyFileEditorModified.set( true ); | |
| 115 | break; | |
| 116 | } | |
| 117 | } | |
| 118 | }; | |
| 119 | ||
| 120 | tabs.addListener( | |
| 121 | (ListChangeListener<Tab>) change -> { | |
| 122 | while( change.next() ) { | |
| 123 | if( change.wasAdded() ) { | |
| 124 | change.getAddedSubList().forEach( | |
| 125 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 126 | .addListener( modifiedListener ) ); | |
| 127 | } | |
| 128 | else if( change.wasRemoved() ) { | |
| 129 | change.getRemoved().forEach( | |
| 130 | ( tab ) -> ((FileEditorTab) tab).modifiedProperty() | |
| 131 | .removeListener( | |
| 132 | modifiedListener ) ); | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | // Changes in the tabs may also change anyFileEditorModified property | |
| 137 | // (e.g. closed modified file) | |
| 138 | modifiedListener.changed( null, null, null ); | |
| 139 | } | |
| 140 | ); | |
| 141 | ||
| 142 | mScrollEventObserver = scrollEventObserver; | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Allows observers to be notified when the current file editor tab changes. | |
| 147 | * | |
| 148 | * @param listener The listener to notify of tab change events. | |
| 149 | */ | |
| 150 | public void addTabSelectionListener( final ChangeListener<Tab> listener ) { | |
| 151 | // Observe the tab so that when a new tab is opened or selected, | |
| 152 | // a notification is kicked off. | |
| 153 | getSelectionModel().selectedItemProperty().addListener( listener ); | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Returns the tab that has keyboard focus. | |
| 158 | * | |
| 159 | * @return A non-null instance. | |
| 160 | */ | |
| 161 | public FileEditorTab getActiveFileEditor() { | |
| 162 | return mActiveFileEditor.get(); | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * Returns the property corresponding to the tab that has focus. | |
| 167 | * | |
| 168 | * @return A non-null instance. | |
| 169 | */ | |
| 170 | public ReadOnlyObjectProperty<FileEditorTab> activeFileEditorProperty() { | |
| 171 | return mActiveFileEditor.getReadOnlyProperty(); | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Property that can answer whether the text has been modified. | |
| 176 | * | |
| 177 | * @return A non-null instance, true meaning the content has not been saved. | |
| 178 | */ | |
| 179 | ReadOnlyBooleanProperty anyFileEditorModifiedProperty() { | |
| 180 | return this.anyFileEditorModified.getReadOnlyProperty(); | |
| 181 | } | |
| 182 | ||
| 183 | /** | |
| 184 | * Creates a new editor instance from the given path. | |
| 185 | * | |
| 186 | * @param path The file to open. | |
| 187 | * @return A non-null instance. | |
| 188 | */ | |
| 189 | private FileEditorTab createFileEditor( final Path path ) { | |
| 190 | assert path != null; | |
| 191 | ||
| 192 | final FileEditorTab tab = new FileEditorTab( path ); | |
| 193 | ||
| 194 | tab.getEditorPane().getScrollPane().estimatedScrollYProperty().addObserver( | |
| 195 | mScrollEventObserver | |
| 196 | ); | |
| 197 | ||
| 198 | tab.setOnCloseRequest( e -> { | |
| 199 | if( !canCloseEditor( tab ) ) { | |
| 200 | e.consume(); | |
| 201 | } | |
| 202 | else if( isActiveFileEditor( tab ) ) { | |
| 203 | // Prevent prompting the user to save when there are no file editor | |
| 204 | // tabs open. | |
| 205 | mActiveFileEditor.set( null ); | |
| 206 | } | |
| 207 | } ); | |
| 208 | ||
| 209 | return tab; | |
| 210 | } | |
| 211 | ||
| 212 | private boolean isActiveFileEditor( final FileEditorTab tab ) { | |
| 213 | return getActiveFileEditor() == tab; | |
| 214 | } | |
| 215 | ||
| 216 | private Path getDefaultPath() { | |
| 217 | final String filename = getDefaultFilename(); | |
| 218 | return (new File( filename )).toPath(); | |
| 219 | } | |
| 220 | ||
| 221 | private String getDefaultFilename() { | |
| 222 | return getSettings().getSetting( "file.default", "untitled.md" ); | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Called when the user selects New from the File menu. | |
| 227 | */ | |
| 228 | void newEditor() { | |
| 229 | final Path defaultPath = getDefaultPath(); | |
| 230 | final FileEditorTab tab = createFileEditor( defaultPath ); | |
| 231 | ||
| 232 | getTabs().add( tab ); | |
| 233 | getSelectionModel().select( tab ); | |
| 234 | } | |
| 235 | ||
| 236 | void openFileDialog() { | |
| 237 | final String title = get( "Dialog.file.choose.open.title" ); | |
| 238 | final FileChooser dialog = createFileChooser( title ); | |
| 239 | final List<File> files = dialog.showOpenMultipleDialog( getWindow() ); | |
| 240 | ||
| 241 | if( files != null ) { | |
| 242 | openFiles( files ); | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Opens the files into new editors, unless one of those files was a | |
| 248 | * definition file. The definition file is loaded into the definition pane, | |
| 249 | * but only the first one selected (multiple definition files will result in a | |
| 250 | * warning). | |
| 251 | * | |
| 252 | * @param files The list of non-definition files that the were requested to | |
| 253 | * open. | |
| 254 | */ | |
| 255 | private void openFiles( final List<File> files ) { | |
| 256 | final List<String> extensions = | |
| 257 | createExtensionFilter( DEFINITION ).getExtensions(); | |
| 258 | final FileTypePredicate predicate = | |
| 259 | new FileTypePredicate( extensions ); | |
| 260 | ||
| 261 | // The user might have opened multiple definitions files. These will | |
| 262 | // be discarded from the text editable files. | |
| 263 | final List<File> definitions | |
| 264 | = files.stream().filter( predicate ).collect( Collectors.toList() ); | |
| 265 | ||
| 266 | // Create a modifiable list to remove any definition files that were | |
| 267 | // opened. | |
| 268 | final List<File> editors = new ArrayList<>( files ); | |
| 269 | ||
| 270 | if( !editors.isEmpty() ) { | |
| 271 | saveLastDirectory( editors.get( 0 ) ); | |
| 272 | } | |
| 273 | ||
| 274 | editors.removeAll( definitions ); | |
| 275 | ||
| 276 | // Open editor-friendly files (e.g,. Markdown, XML) in new tabs. | |
| 277 | if( !editors.isEmpty() ) { | |
| 278 | openEditors( editors, 0 ); | |
| 279 | } | |
| 280 | ||
| 281 | if( !definitions.isEmpty() ) { | |
| 282 | openDefinition( definitions.get( 0 ) ); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | private void openEditors( final List<File> files, final int activeIndex ) { | |
| 287 | final int fileTally = files.size(); | |
| 288 | final List<Tab> tabs = getTabs(); | |
| 289 | ||
| 290 | // Close single unmodified "Untitled" tab. | |
| 291 | if( tabs.size() == 1 ) { | |
| 292 | final FileEditorTab fileEditor = (FileEditorTab) (tabs.get( 0 )); | |
| 293 | ||
| 294 | if( fileEditor.getPath() == null && !fileEditor.isModified() ) { | |
| 295 | closeEditor( fileEditor, false ); | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | for( int i = 0; i < fileTally; i++ ) { | |
| 300 | final Path path = files.get( i ).toPath(); | |
| 301 | ||
| 302 | FileEditorTab fileEditorTab = findEditor( path ); | |
| 303 | ||
| 304 | // Only open new files. | |
| 305 | if( fileEditorTab == null ) { | |
| 306 | fileEditorTab = createFileEditor( path ); | |
| 307 | getTabs().add( fileEditorTab ); | |
| 308 | } | |
| 309 | ||
| 310 | // Select the first file in the list. | |
| 311 | if( i == activeIndex ) { | |
| 312 | getSelectionModel().select( fileEditorTab ); | |
| 313 | } | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | /** | |
| 318 | * Returns a property that changes when a new definition file is opened. | |
| 319 | * | |
| 320 | * @return The path to a definition file that was opened. | |
| 321 | */ | |
| 322 | public ReadOnlyObjectProperty<Path> onOpenDefinitionFileProperty() { | |
| 323 | return getOnOpenDefinitionFile().getReadOnlyProperty(); | |
| 324 | } | |
| 325 | ||
| 326 | private ReadOnlyObjectWrapper<Path> getOnOpenDefinitionFile() { | |
| 327 | return this.openDefinition; | |
| 328 | } | |
| 329 | ||
| 330 | /** | |
| 331 | * Called when the user has opened a definition file (using the file open | |
| 332 | * dialog box). This will replace the current set of definitions for the | |
| 333 | * active tab. | |
| 334 | * | |
| 335 | * @param definition The file to open. | |
| 336 | */ | |
| 337 | private void openDefinition( final File definition ) { | |
| 338 | // TODO: Prevent reading this file twice when a new text document is opened. | |
| 339 | // (might be a matter of checking the value first). | |
| 340 | getOnOpenDefinitionFile().set( definition.toPath() ); | |
| 341 | } | |
| 342 | ||
| 343 | /** | |
| 344 | * Called when the contents of the editor are to be saved. | |
| 345 | * | |
| 346 | * @param tab The tab containing content to save. | |
| 347 | * @return true The contents were saved (or needn't be saved). | |
| 348 | */ | |
| 349 | public boolean saveEditor( final FileEditorTab tab ) { | |
| 350 | if( tab == null || !tab.isModified() ) { | |
| 351 | return true; | |
| 352 | } | |
| 353 | ||
| 354 | return tab.getPath() == null ? saveEditorAs( tab ) : tab.save(); | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * Opens the Save As dialog for the user to save the content under a new | |
| 359 | * path. | |
| 360 | * | |
| 361 | * @param tab The tab with contents to save. | |
| 362 | * @return true The contents were saved, or the tab was null. | |
| 363 | */ | |
| 364 | public boolean saveEditorAs( final FileEditorTab tab ) { | |
| 365 | if( tab == null ) { | |
| 366 | return true; | |
| 367 | } | |
| 368 | ||
| 369 | getSelectionModel().select( tab ); | |
| 370 | ||
| 371 | final FileChooser fileChooser = createFileChooser( get( | |
| 372 | "Dialog.file.choose.save.title" ) ); | |
| 373 | final File file = fileChooser.showSaveDialog( getWindow() ); | |
| 374 | if( file == null ) { | |
| 375 | return false; | |
| 376 | } | |
| 377 | ||
| 378 | saveLastDirectory( file ); | |
| 379 | tab.setPath( file.toPath() ); | |
| 380 | ||
| 381 | return tab.save(); | |
| 382 | } | |
| 383 | ||
| 384 | void saveAllEditors() { | |
| 385 | for( final FileEditorTab fileEditor : getAllEditors() ) { | |
| 386 | saveEditor( fileEditor ); | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | /** | |
| 391 | * Answers whether the file has had modifications. ' | |
| 392 | * | |
| 393 | * @param tab THe tab to check for modifications. | |
| 394 | * @return false The file is unmodified. | |
| 395 | */ | |
| 396 | @SuppressWarnings("BooleanMethodIsAlwaysInverted") | |
| 397 | boolean canCloseEditor( final FileEditorTab tab ) { | |
| 398 | final AtomicReference<Boolean> canClose = new AtomicReference<>(); | |
| 399 | canClose.set( true ); | |
| 400 | ||
| 401 | if( tab.isModified() ) { | |
| 402 | final Notification message = getNotifyService().createNotification( | |
| 403 | Messages.get( "Alert.file.close.title" ), | |
| 404 | Messages.get( "Alert.file.close.text" ), | |
| 405 | tab.getText() | |
| 406 | ); | |
| 407 | ||
| 408 | final Alert confirmSave = getNotifyService().createConfirmation( | |
| 409 | getWindow(), message ); | |
| 410 | ||
| 411 | final Optional<ButtonType> buttonType = confirmSave.showAndWait(); | |
| 412 | ||
| 413 | buttonType.ifPresent( | |
| 414 | save -> canClose.set( | |
| 415 | save == YES ? saveEditor( tab ) : save == ButtonType.NO | |
| 416 | ) | |
| 417 | ); | |
| 418 | } | |
| 419 | ||
| 420 | return canClose.get(); | |
| 421 | } | |
| 422 | ||
| 423 | boolean closeEditor( final FileEditorTab tab, final boolean save ) { | |
| 424 | if( tab == null ) { | |
| 425 | return true; | |
| 426 | } | |
| 427 | ||
| 428 | if( save ) { | |
| 429 | Event event = new Event( tab, tab, Tab.TAB_CLOSE_REQUEST_EVENT ); | |
| 430 | Event.fireEvent( tab, event ); | |
| 431 | ||
| 432 | if( event.isConsumed() ) { | |
| 433 | return false; | |
| 434 | } | |
| 435 | } | |
| 436 | ||
| 437 | getTabs().remove( tab ); | |
| 438 | ||
| 439 | if( tab.getOnClosed() != null ) { | |
| 440 | Event.fireEvent( tab, new Event( Tab.CLOSED_EVENT ) ); | |
| 441 | } | |
| 442 | ||
| 443 | return true; | |
| 444 | } | |
| 445 | ||
| 446 | boolean closeAllEditors() { | |
| 447 | final FileEditorTab[] allEditors = getAllEditors(); | |
| 448 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 449 | ||
| 450 | // try to save active tab first because in case the user decides to cancel, | |
| 451 | // then it stays active | |
| 452 | if( activeEditor != null && !canCloseEditor( activeEditor ) ) { | |
| 453 | return false; | |
| 454 | } | |
| 455 | ||
| 456 | // This should be called any time a tab changes. | |
| 457 | persistPreferences(); | |
| 458 | ||
| 459 | // save modified tabs | |
| 460 | for( int i = 0; i < allEditors.length; i++ ) { | |
| 461 | final FileEditorTab fileEditor = allEditors[ i ]; | |
| 462 | ||
| 463 | if( fileEditor == activeEditor ) { | |
| 464 | continue; | |
| 465 | } | |
| 466 | ||
| 467 | if( fileEditor.isModified() ) { | |
| 468 | // activate the modified tab to make its modified content visible to | |
| 469 | // the user | |
| 470 | getSelectionModel().select( i ); | |
| 471 | ||
| 472 | if( !canCloseEditor( fileEditor ) ) { | |
| 473 | return false; | |
| 474 | } | |
| 475 | } | |
| 476 | } | |
| 477 | ||
| 478 | // Close all tabs. | |
| 479 | for( final FileEditorTab fileEditor : allEditors ) { | |
| 480 | if( !closeEditor( fileEditor, false ) ) { | |
| 481 | return false; | |
| 482 | } | |
| 483 | } | |
| 484 | ||
| 485 | return getTabs().isEmpty(); | |
| 486 | } | |
| 487 | ||
| 488 | private FileEditorTab[] getAllEditors() { | |
| 489 | final ObservableList<Tab> tabs = getTabs(); | |
| 490 | final int length = tabs.size(); | |
| 491 | final FileEditorTab[] allEditors = new FileEditorTab[ length ]; | |
| 492 | ||
| 493 | for( int i = 0; i < length; i++ ) { | |
| 494 | allEditors[ i ] = (FileEditorTab) tabs.get( i ); | |
| 495 | } | |
| 496 | ||
| 497 | return allEditors; | |
| 498 | } | |
| 499 | ||
| 500 | /** | |
| 501 | * Returns the file editor tab that has the given path. | |
| 502 | * | |
| 503 | * @return null No file editor tab for the given path was found. | |
| 504 | */ | |
| 505 | private FileEditorTab findEditor( final Path path ) { | |
| 506 | for( final Tab tab : getTabs() ) { | |
| 507 | final FileEditorTab fileEditor = (FileEditorTab) tab; | |
| 508 | ||
| 509 | if( fileEditor.isPath( path ) ) { | |
| 510 | return fileEditor; | |
| 511 | } | |
| 512 | } | |
| 513 | ||
| 514 | return null; | |
| 515 | } | |
| 516 | ||
| 517 | private FileChooser createFileChooser( String title ) { | |
| 518 | final FileChooser fileChooser = new FileChooser(); | |
| 519 | ||
| 520 | fileChooser.setTitle( title ); | |
| 521 | fileChooser.getExtensionFilters().addAll( | |
| 522 | createExtensionFilters() ); | |
| 523 | ||
| 524 | final String lastDirectory = getPreferences().get( "lastDirectory", null ); | |
| 525 | File file = new File( (lastDirectory != null) ? lastDirectory : "." ); | |
| 526 | ||
| 527 | if( !file.isDirectory() ) { | |
| 528 | file = new File( "." ); | |
| 529 | } | |
| 530 | ||
| 531 | fileChooser.setInitialDirectory( file ); | |
| 532 | return fileChooser; | |
| 533 | } | |
| 534 | ||
| 535 | private List<ExtensionFilter> createExtensionFilters() { | |
| 536 | final List<ExtensionFilter> list = new ArrayList<>(); | |
| 537 | ||
| 538 | // TODO: Return a list of all properties that match the filter prefix. | |
| 539 | // This will allow dynamic filters to be added and removed just by | |
| 540 | // updating the properties file. | |
| 541 | list.add( createExtensionFilter( ALL ) ); | |
| 542 | list.add( createExtensionFilter( SOURCE ) ); | |
| 543 | list.add( createExtensionFilter( DEFINITION ) ); | |
| 544 | list.add( createExtensionFilter( XML ) ); | |
| 545 | return list; | |
| 546 | } | |
| 547 | ||
| 548 | /** | |
| 549 | * Returns a filter for file name extensions recognized by the application | |
| 550 | * that can be opened by the user. | |
| 551 | * | |
| 552 | * @param filetype Used to find the globbing pattern for extensions. | |
| 553 | * @return A filename filter suitable for use by a FileDialog instance. | |
| 554 | */ | |
| 555 | private ExtensionFilter createExtensionFilter( final FileType filetype ) { | |
| 556 | final String tKey = String.format( "%s.title.%s", | |
| 557 | FILTER_EXTENSION_TITLES, | |
| 558 | filetype ); | |
| 559 | final String eKey = String.format( "%s.%s", GLOB_PREFIX_FILE, filetype ); | |
| 560 | ||
| 561 | return new ExtensionFilter( Messages.get( tKey ), getExtensions( eKey ) ); | |
| 562 | } | |
| 563 | ||
| 564 | private List<String> getExtensions( final String key ) { | |
| 565 | return getSettings().getStringSettingList( key ); | |
| 566 | } | |
| 567 | ||
| 568 | private void saveLastDirectory( final File file ) { | |
| 569 | getPreferences().put( "lastDirectory", file.getParent() ); | |
| 570 | } | |
| 571 | ||
| 572 | public void restorePreferences() { | |
| 573 | int activeIndex = 0; | |
| 574 | ||
| 575 | final Preferences preferences = getPreferences(); | |
| 576 | final String[] fileNames = Utils.getPrefsStrings( preferences, "file" ); | |
| 577 | final String activeFileName = preferences.get( "activeFile", null ); | |
| 578 | ||
| 579 | final List<File> files = new ArrayList<>( fileNames.length ); | |
| 580 | ||
| 581 | for( final String fileName : fileNames ) { | |
| 582 | final File file = new File( fileName ); | |
| 583 | ||
| 584 | if( file.exists() ) { | |
| 585 | files.add( file ); | |
| 586 | ||
| 587 | if( fileName.equals( activeFileName ) ) { | |
| 588 | activeIndex = files.size() - 1; | |
| 589 | } | |
| 590 | } | |
| 591 | } | |
| 592 | ||
| 593 | if( files.isEmpty() ) { | |
| 594 | newEditor(); | |
| 595 | } | |
| 596 | else { | |
| 597 | openEditors( files, activeIndex ); | |
| 598 | } | |
| 599 | } | |
| 600 | ||
| 601 | public void persistPreferences() { | |
| 602 | final ObservableList<Tab> allEditors = getTabs(); | |
| 603 | final List<String> fileNames = new ArrayList<>( allEditors.size() ); | |
| 604 | ||
| 605 | for( final Tab tab : allEditors ) { | |
| 606 | final FileEditorTab fileEditor = (FileEditorTab) tab; | |
| 607 | final Path filePath = fileEditor.getPath(); | |
| 608 | ||
| 609 | if( filePath != null ) { | |
| 610 | fileNames.add( filePath.toString() ); | |
| 611 | } | |
| 612 | } | |
| 613 | ||
| 614 | final Preferences preferences = getPreferences(); | |
| 615 | Utils.putPrefsStrings( preferences, | |
| 616 | "file", | |
| 617 | fileNames.toArray( new String[ 0 ] ) ); | |
| 618 | ||
| 619 | final FileEditorTab activeEditor = getActiveFileEditor(); | |
| 620 | final Path filePath = activeEditor == null ? null : activeEditor.getPath(); | |
| 621 | ||
| 622 | if( filePath == null ) { | |
| 623 | preferences.remove( "activeFile" ); | |
| 624 | } | |
| 625 | else { | |
| 626 | preferences.put( "activeFile", filePath.toString() ); | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | private Notifier getNotifyService() { | |
| 631 | return sNotifier; | |
| 632 | } | |
| 633 | ||
| 634 | private Settings getSettings() { | |
| 635 | return sSettings; | |
| 636 | } | |
| 637 | ||
| 638 | protected Options getOptions() { | |
| 639 | return sOptions; | |
| 632 | 640 | } |
| 633 | 641 |
| 50 | 50 | */ |
| 51 | 51 | public static void main( final String[] args ) throws IOException { |
| 52 | // Shhh. | |
| 53 | System.err.close(); | |
| 54 | ||
| 52 | 55 | showAppInfo(); |
| 53 | 56 | Main.main( args ); |
| 54 | 57 | } |
| 55 | 58 | |
| 59 | @SuppressWarnings("RedundantStringFormatCall") | |
| 56 | 60 | private static void showAppInfo() throws IOException { |
| 57 | 61 | out( format( "%s version %s", getTitle(), getVersion() ) ); |
| 33 | 33 | import com.scrivenvar.definition.MapInterpolator; |
| 34 | 34 | import com.scrivenvar.definition.yaml.YamlDefinitionSource; |
| 35 | import com.scrivenvar.dialogs.RScriptDialog; | |
| 36 | import com.scrivenvar.editors.EditorPane; | |
| 37 | import com.scrivenvar.editors.VariableNameInjector; | |
| 38 | import com.scrivenvar.editors.markdown.MarkdownEditorPane; | |
| 39 | import com.scrivenvar.preview.HTMLPreviewPane; | |
| 40 | import com.scrivenvar.processors.Processor; | |
| 41 | import com.scrivenvar.processors.ProcessorFactory; | |
| 42 | import com.scrivenvar.service.Options; | |
| 43 | import com.scrivenvar.service.Settings; | |
| 44 | import com.scrivenvar.service.Snitch; | |
| 45 | import com.scrivenvar.service.events.Notifier; | |
| 46 | import com.scrivenvar.util.Action; | |
| 47 | import com.scrivenvar.util.ActionUtils; | |
| 48 | import javafx.application.Platform; | |
| 49 | import javafx.beans.binding.Bindings; | |
| 50 | import javafx.beans.binding.BooleanBinding; | |
| 51 | import javafx.beans.property.BooleanProperty; | |
| 52 | import javafx.beans.property.SimpleBooleanProperty; | |
| 53 | import javafx.beans.value.ObservableBooleanValue; | |
| 54 | import javafx.beans.value.ObservableValue; | |
| 55 | import javafx.collections.ListChangeListener.Change; | |
| 56 | import javafx.collections.ObservableList; | |
| 57 | import javafx.event.Event; | |
| 58 | import javafx.event.EventHandler; | |
| 59 | import javafx.geometry.Pos; | |
| 60 | import javafx.scene.Node; | |
| 61 | import javafx.scene.Scene; | |
| 62 | import javafx.scene.control.*; | |
| 63 | import javafx.scene.control.Alert.AlertType; | |
| 64 | import javafx.scene.image.Image; | |
| 65 | import javafx.scene.image.ImageView; | |
| 66 | import javafx.scene.input.KeyEvent; | |
| 67 | import javafx.scene.layout.BorderPane; | |
| 68 | import javafx.scene.layout.VBox; | |
| 69 | import javafx.scene.text.Text; | |
| 70 | import javafx.stage.Window; | |
| 71 | import javafx.stage.WindowEvent; | |
| 72 | import javafx.util.Duration; | |
| 73 | import org.controlsfx.control.StatusBar; | |
| 74 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 75 | ||
| 76 | import java.io.File; | |
| 77 | import java.nio.file.Path; | |
| 78 | import java.util.*; | |
| 79 | import java.util.function.Function; | |
| 80 | import java.util.prefs.Preferences; | |
| 81 | ||
| 82 | import static com.scrivenvar.Constants.*; | |
| 83 | import static com.scrivenvar.Messages.get; | |
| 84 | import static com.scrivenvar.Messages.getLiteral; | |
| 85 | import static com.scrivenvar.util.StageState.*; | |
| 86 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*; | |
| 87 | import static javafx.event.Event.fireEvent; | |
| 88 | import static javafx.scene.input.KeyCode.ENTER; | |
| 89 | import static javafx.scene.input.KeyCode.TAB; | |
| 90 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 91 | ||
| 92 | /** | |
| 93 | * Main window containing a tab pane in the center for file editors. | |
| 94 | * | |
| 95 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 96 | */ | |
| 97 | public class MainWindow implements Observer { | |
| 98 | ||
| 99 | private final Options mOptions = Services.load( Options.class ); | |
| 100 | private final Snitch mSnitch = Services.load( Snitch.class ); | |
| 101 | private final Settings mSettings = Services.load( Settings.class ); | |
| 102 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 103 | ||
| 104 | private final Scene mScene; | |
| 105 | private final StatusBar mStatusBar; | |
| 106 | private final Text mLineNumberText; | |
| 107 | private final TextField mFindTextField; | |
| 108 | ||
| 109 | private DefinitionSource mDefinitionSource = createDefaultDefinitionSource(); | |
| 110 | private final DefinitionPane mDefinitionPane = new DefinitionPane(); | |
| 111 | private final HTMLPreviewPane mPreviewPane = new HTMLPreviewPane(); | |
| 112 | private FileEditorTabPane fileEditorPane; | |
| 113 | ||
| 114 | /** | |
| 115 | * Prevents re-instantiation of processing classes. | |
| 116 | */ | |
| 117 | private final Map<FileEditorTab, Processor<String>> mProcessors = | |
| 118 | new HashMap<>(); | |
| 119 | ||
| 120 | private final Map<String, String> mResolvedMap = | |
| 121 | new HashMap<>( DEFAULT_MAP_SIZE ); | |
| 122 | ||
| 123 | /** | |
| 124 | * Listens on the definition pane for double-click events. | |
| 125 | */ | |
| 126 | private VariableNameInjector variableNameInjector; | |
| 127 | ||
| 128 | /** | |
| 129 | * Called when the definition data is changed. | |
| 130 | */ | |
| 131 | final EventHandler<TreeItem.TreeModificationEvent<Event>> mTreeHandler = | |
| 132 | event -> { | |
| 133 | exportDefinitions( getDefinitionPath() ); | |
| 134 | interpolateResolvedMap(); | |
| 135 | refreshActiveTab(); | |
| 136 | }; | |
| 137 | ||
| 138 | final EventHandler<? super KeyEvent> mDefinitionKeyHandler = | |
| 139 | event -> { | |
| 140 | if( event.getCode() == ENTER ) { | |
| 141 | getVariableNameInjector().injectSelectedItem(); | |
| 142 | } | |
| 143 | }; | |
| 144 | ||
| 145 | final EventHandler<? super KeyEvent> mEditorKeyHandler = | |
| 146 | (EventHandler<KeyEvent>) event -> { | |
| 147 | if( event.getCode() == TAB ) { | |
| 148 | getDefinitionPane().requestFocus(); | |
| 149 | event.consume(); | |
| 150 | } | |
| 151 | }; | |
| 152 | ||
| 153 | public MainWindow() { | |
| 154 | mStatusBar = createStatusBar(); | |
| 155 | mLineNumberText = createLineNumberText(); | |
| 156 | mFindTextField = createFindTextField(); | |
| 157 | mScene = createScene(); | |
| 158 | ||
| 159 | initLayout(); | |
| 160 | initFindInput(); | |
| 161 | initSnitch(); | |
| 162 | initDefinitionListener(); | |
| 163 | initTabAddedListener(); | |
| 164 | initTabChangedListener(); | |
| 165 | initPreferences(); | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Watch for changes to external files. In particular, this awaits | |
| 170 | * modifications to any XSL files associated with XML files being edited. When | |
| 171 | * an XSL file is modified (external to the application), the snitch's ears | |
| 172 | * perk up and the file is reloaded. This keeps the XSL transformation up to | |
| 173 | * date with what's on the file system. | |
| 174 | */ | |
| 175 | private void initSnitch() { | |
| 176 | getSnitch().addObserver( this ); | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key | |
| 181 | * presses. | |
| 182 | */ | |
| 183 | private void initFindInput() { | |
| 184 | final TextField input = getFindTextField(); | |
| 185 | ||
| 186 | input.setOnKeyPressed( ( KeyEvent event ) -> { | |
| 187 | switch( event.getCode() ) { | |
| 188 | case F3: | |
| 189 | case ENTER: | |
| 190 | findNext(); | |
| 191 | break; | |
| 192 | case F: | |
| 193 | if( !event.isControlDown() ) { | |
| 194 | break; | |
| 195 | } | |
| 196 | case ESCAPE: | |
| 197 | getStatusBar().setGraphic( null ); | |
| 198 | getActiveFileEditor().getEditorPane().requestFocus(); | |
| 199 | break; | |
| 200 | } | |
| 201 | } ); | |
| 202 | ||
| 203 | // Remove when the input field loses focus. | |
| 204 | input.focusedProperty().addListener( | |
| 205 | ( | |
| 206 | final ObservableValue<? extends Boolean> focused, | |
| 207 | final Boolean oFocus, | |
| 208 | final Boolean nFocus ) -> { | |
| 209 | if( !nFocus ) { | |
| 210 | getStatusBar().setGraphic( null ); | |
| 211 | } | |
| 212 | } | |
| 213 | ); | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Listen for {@link FileEditorTabPane} to receive open definition file event. | |
| 218 | */ | |
| 219 | private void initDefinitionListener() { | |
| 220 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 221 | ( final ObservableValue<? extends Path> file, | |
| 222 | final Path oldPath, final Path newPath ) -> { | |
| 223 | // Indirectly refresh the resolved map. | |
| 224 | resetProcessors(); | |
| 225 | ||
| 226 | openDefinitions( newPath ); | |
| 227 | ||
| 228 | // Will create new processors and therefore a new resolved map. | |
| 229 | refreshActiveTab(); | |
| 230 | } | |
| 231 | ); | |
| 232 | } | |
| 233 | ||
| 234 | /** | |
| 235 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 236 | * that the preview pane refreshes as necessary. | |
| 237 | */ | |
| 238 | private void initTabAddedListener() { | |
| 239 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 240 | ||
| 241 | // Make sure the text processor kicks off when new files are opened. | |
| 242 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 243 | ||
| 244 | // Update the preview pane on tab changes. | |
| 245 | tabs.addListener( | |
| 246 | ( final Change<? extends Tab> change ) -> { | |
| 247 | while( change.next() ) { | |
| 248 | if( change.wasAdded() ) { | |
| 249 | // Multiple tabs can be added simultaneously. | |
| 250 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 251 | final FileEditorTab tab = (FileEditorTab) newTab; | |
| 252 | ||
| 253 | initTextChangeListener( tab ); | |
| 254 | initCaretParagraphListener( tab ); | |
| 255 | initKeyboardEventListeners( tab ); | |
| 256 | // initSyntaxListener( tab ); | |
| 257 | } | |
| 258 | } | |
| 259 | } | |
| 260 | } | |
| 261 | ); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Reloads the preferences from the previous session. | |
| 266 | */ | |
| 267 | private void initPreferences() { | |
| 268 | restoreDefinitionPane(); | |
| 269 | getFileEditorPane().restorePreferences(); | |
| 270 | } | |
| 271 | ||
| 272 | /** | |
| 273 | * Listen for new tab selection events. | |
| 274 | */ | |
| 275 | private void initTabChangedListener() { | |
| 276 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 277 | ||
| 278 | // Update the preview pane changing tabs. | |
| 279 | editorPane.addTabSelectionListener( | |
| 280 | ( ObservableValue<? extends Tab> tabPane, | |
| 281 | final Tab oldTab, final Tab newTab ) -> { | |
| 282 | updateVariableNameInjector(); | |
| 283 | ||
| 284 | // If there was no old tab, then this is a first time load, which | |
| 285 | // can be ignored. | |
| 286 | if( oldTab != null ) { | |
| 287 | if( newTab == null ) { | |
| 288 | closeRemainingTab(); | |
| 289 | } | |
| 290 | else { | |
| 291 | // Update the preview with the edited text. | |
| 292 | refreshSelectedTab( (FileEditorTab) newTab ); | |
| 293 | } | |
| 294 | } | |
| 295 | } | |
| 296 | ); | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Ensure that the keyboard events are received when a new tab is added | |
| 301 | * to the user interface. | |
| 302 | * | |
| 303 | * @param tab The tab that can trigger keyboard events, such as control+space. | |
| 304 | */ | |
| 305 | private void initKeyboardEventListeners( final FileEditorTab tab ) { | |
| 306 | final VariableNameInjector vin = getVariableNameInjector(); | |
| 307 | vin.initKeyboardEventListeners( tab ); | |
| 308 | ||
| 309 | tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler ); | |
| 310 | } | |
| 311 | ||
| 312 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 313 | tab.addTextChangeListener( | |
| 314 | ( ObservableValue<? extends String> editor, | |
| 315 | final String oldValue, final String newValue ) -> | |
| 316 | refreshSelectedTab( tab ) | |
| 317 | ); | |
| 318 | } | |
| 319 | ||
| 320 | private void initCaretParagraphListener( final FileEditorTab tab ) { | |
| 321 | tab.addCaretParagraphListener( | |
| 322 | ( ObservableValue<? extends Integer> editor, | |
| 323 | final Integer oldValue, final Integer newValue ) -> | |
| 324 | refreshSelectedTab( tab ) | |
| 325 | ); | |
| 326 | } | |
| 327 | ||
| 328 | private void updateVariableNameInjector() { | |
| 329 | getVariableNameInjector().setFileEditorTab( getActiveFileEditor() ); | |
| 330 | } | |
| 331 | ||
| 332 | private void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 333 | this.variableNameInjector = injector; | |
| 334 | } | |
| 335 | ||
| 336 | private synchronized VariableNameInjector getVariableNameInjector() { | |
| 337 | if( this.variableNameInjector == null ) { | |
| 338 | final VariableNameInjector vin = createVariableNameInjector(); | |
| 339 | setVariableNameInjector( vin ); | |
| 340 | } | |
| 341 | ||
| 342 | return this.variableNameInjector; | |
| 343 | } | |
| 344 | ||
| 345 | private VariableNameInjector createVariableNameInjector() { | |
| 346 | final FileEditorTab tab = getActiveFileEditor(); | |
| 347 | final DefinitionPane pane = getDefinitionPane(); | |
| 348 | ||
| 349 | return new VariableNameInjector( tab, pane ); | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 354 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 355 | * or the file tab changes. | |
| 356 | * | |
| 357 | * @param tab The file editor tab that has been changed in some fashion. | |
| 358 | */ | |
| 359 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 360 | if( tab == null ) { | |
| 361 | return; | |
| 362 | } | |
| 363 | ||
| 364 | getPreviewPane().setPath( tab.getPath() ); | |
| 365 | ||
| 366 | // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29 | |
| 367 | final Position p = tab.getCaretOffset(); | |
| 368 | getLineNumberText().setText( | |
| 369 | get( STATUS_BAR_LINE, | |
| 370 | p.getMajor() + 1, | |
| 371 | p.getMinor() + 1, | |
| 372 | tab.getCaretPosition() + 1 | |
| 373 | ) | |
| 374 | ); | |
| 375 | ||
| 376 | Processor<String> processor = getProcessors().get( tab ); | |
| 377 | ||
| 378 | if( processor == null ) { | |
| 379 | processor = createProcessor( tab ); | |
| 380 | getProcessors().put( tab, processor ); | |
| 381 | } | |
| 382 | ||
| 383 | try { | |
| 384 | processor.processChain( tab.getEditorText() ); | |
| 385 | } catch( final Exception ex ) { | |
| 386 | error( ex ); | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | private void refreshActiveTab() { | |
| 391 | refreshSelectedTab( getActiveFileEditor() ); | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Used to find text in the active file editor window. | |
| 396 | */ | |
| 397 | private void find() { | |
| 398 | final TextField input = getFindTextField(); | |
| 399 | getStatusBar().setGraphic( input ); | |
| 400 | input.requestFocus(); | |
| 401 | } | |
| 402 | ||
| 403 | public void findNext() { | |
| 404 | getActiveFileEditor().searchNext( getFindTextField().getText() ); | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Returns the variable map of interpolated definitions. | |
| 409 | * | |
| 410 | * @return A map to help dereference variables. | |
| 411 | */ | |
| 412 | private Map<String, String> getResolvedMap() { | |
| 413 | return mResolvedMap; | |
| 414 | } | |
| 415 | ||
| 416 | private void interpolateResolvedMap() { | |
| 417 | final Map<String, String> treeMap = getDefinitionPane().toMap(); | |
| 418 | final Map<String, String> map = new HashMap<>( treeMap ); | |
| 419 | MapInterpolator.interpolate( map ); | |
| 420 | ||
| 421 | getResolvedMap().clear(); | |
| 422 | getResolvedMap().putAll( map ); | |
| 423 | } | |
| 424 | ||
| 425 | /** | |
| 426 | * Called when a definition source is opened. | |
| 427 | * | |
| 428 | * @param path Path to the definition source that was opened. | |
| 429 | */ | |
| 430 | private void openDefinitions( final Path path ) { | |
| 431 | try { | |
| 432 | final DefinitionSource ds = createDefinitionSource( path ); | |
| 433 | setDefinitionSource( ds ); | |
| 434 | storeDefinitionSourceFilename( path ); | |
| 435 | ||
| 436 | final Tooltip tooltipPath = new Tooltip( path.toString() ); | |
| 437 | tooltipPath.setShowDelay( Duration.millis( 200 ) ); | |
| 438 | ||
| 439 | final DefinitionPane pane = getDefinitionPane(); | |
| 440 | pane.update( ds ); | |
| 441 | pane.addTreeChangeHandler( mTreeHandler ); | |
| 442 | pane.addKeyEventHandler( mDefinitionKeyHandler ); | |
| 443 | pane.filenameProperty().setValue( path.getFileName().toString() ); | |
| 444 | pane.setTooltip( tooltipPath ); | |
| 445 | ||
| 446 | interpolateResolvedMap(); | |
| 447 | } catch( final Exception e ) { | |
| 448 | error( e ); | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 452 | private void exportDefinitions( final Path path ) { | |
| 453 | try { | |
| 454 | final DefinitionPane pane = getDefinitionPane(); | |
| 455 | final TreeItem<String> root = pane.getTreeView().getRoot(); | |
| 456 | final TreeItem<String> problemChild = pane.isTreeWellFormed(); | |
| 457 | ||
| 458 | if( problemChild == null ) { | |
| 459 | getDefinitionSource().getTreeAdapter().export( root, path ); | |
| 460 | getNotifier().clear(); | |
| 461 | } | |
| 462 | else { | |
| 463 | final String msg = get( "yaml.error.tree.form", | |
| 464 | problemChild.getValue() ); | |
| 465 | getNotifier().notify( msg ); | |
| 466 | } | |
| 467 | } catch( final Exception e ) { | |
| 468 | error( e ); | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | private Path getDefinitionPath() { | |
| 473 | final String source = getPreferences().get( | |
| 474 | PERSIST_DEFINITION_SOURCE, "" ); | |
| 475 | ||
| 476 | return new File( | |
| 477 | source.isBlank() | |
| 478 | ? getSetting( "file.definition.default", "variables.yaml" ) | |
| 479 | : source | |
| 480 | ).toPath(); | |
| 481 | } | |
| 482 | ||
| 483 | private void restoreDefinitionPane() { | |
| 484 | openDefinitions( getDefinitionPath() ); | |
| 485 | } | |
| 486 | ||
| 487 | private void storeDefinitionSourceFilename( final Path path ) { | |
| 488 | getPreferences().put( PERSIST_DEFINITION_SOURCE, path.toString() ); | |
| 489 | } | |
| 490 | ||
| 491 | /** | |
| 492 | * Called when the last open tab is closed to clear the preview pane. | |
| 493 | */ | |
| 494 | private void closeRemainingTab() { | |
| 495 | getPreviewPane().clear(); | |
| 496 | } | |
| 497 | ||
| 498 | /** | |
| 499 | * Called when an exception occurs that warrants the user's attention. | |
| 500 | * | |
| 501 | * @param e The exception with a message that the user should know about. | |
| 502 | */ | |
| 503 | private void error( final Exception e ) { | |
| 504 | getNotifier().notify( e ); | |
| 505 | } | |
| 506 | ||
| 507 | //---- File actions ------------------------------------------------------- | |
| 508 | ||
| 509 | /** | |
| 510 | * Called when an observable instance has changed. This is called by both the | |
| 511 | * snitch service and the notify service. The snitch service can be called for | |
| 512 | * different file types, including definition sources. | |
| 513 | * | |
| 514 | * @param observable The observed instance. | |
| 515 | * @param value The noteworthy item. | |
| 516 | */ | |
| 517 | @Override | |
| 518 | public void update( final Observable observable, final Object value ) { | |
| 519 | if( value != null ) { | |
| 520 | if( observable instanceof Snitch && value instanceof Path ) { | |
| 521 | updateSelectedTab(); | |
| 522 | } | |
| 523 | else if( observable instanceof Notifier && value instanceof String ) { | |
| 524 | updateStatusBar( (String) value ); | |
| 525 | } | |
| 526 | } | |
| 527 | } | |
| 528 | ||
| 529 | /** | |
| 530 | * Updates the status bar to show the given message. | |
| 531 | * | |
| 532 | * @param s The message to show in the status bar. | |
| 533 | */ | |
| 534 | private void updateStatusBar( final String s ) { | |
| 535 | Platform.runLater( | |
| 536 | () -> { | |
| 537 | final int index = s.indexOf( '\n' ); | |
| 538 | final String message = s.substring( | |
| 539 | 0, index > 0 ? index : s.length() ); | |
| 540 | ||
| 541 | getStatusBar().setText( message ); | |
| 542 | } | |
| 543 | ); | |
| 544 | } | |
| 545 | ||
| 546 | /** | |
| 547 | * Called when a file has been modified. | |
| 548 | */ | |
| 549 | private void updateSelectedTab() { | |
| 550 | Platform.runLater( | |
| 551 | () -> { | |
| 552 | // Brute-force XSLT file reload by re-instantiating all processors. | |
| 553 | resetProcessors(); | |
| 554 | refreshActiveTab(); | |
| 555 | } | |
| 556 | ); | |
| 557 | } | |
| 558 | ||
| 559 | /** | |
| 560 | * After resetting the processors, they will refresh anew to be up-to-date | |
| 561 | * with the files (text and definition) currently loaded into the editor. | |
| 562 | */ | |
| 563 | private void resetProcessors() { | |
| 564 | getProcessors().clear(); | |
| 565 | } | |
| 566 | ||
| 567 | //---- File actions ------------------------------------------------------- | |
| 568 | private void fileNew() { | |
| 569 | getFileEditorPane().newEditor(); | |
| 570 | } | |
| 571 | ||
| 572 | private void fileOpen() { | |
| 573 | getFileEditorPane().openFileDialog(); | |
| 574 | } | |
| 575 | ||
| 576 | private void fileClose() { | |
| 577 | getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | |
| 578 | } | |
| 579 | ||
| 580 | private void fileCloseAll() { | |
| 581 | getFileEditorPane().closeAllEditors(); | |
| 582 | } | |
| 583 | ||
| 584 | private void fileSave() { | |
| 585 | getFileEditorPane().saveEditor( getActiveFileEditor() ); | |
| 586 | } | |
| 587 | ||
| 588 | private void fileSaveAs() { | |
| 589 | final FileEditorTab editor = getActiveFileEditor(); | |
| 590 | getFileEditorPane().saveEditorAs( editor ); | |
| 591 | getProcessors().remove( editor ); | |
| 592 | ||
| 593 | try { | |
| 594 | refreshSelectedTab( editor ); | |
| 595 | } catch( final Exception ex ) { | |
| 596 | getNotifier().notify( ex ); | |
| 597 | } | |
| 598 | } | |
| 599 | ||
| 600 | private void fileSaveAll() { | |
| 601 | getFileEditorPane().saveAllEditors(); | |
| 602 | } | |
| 603 | ||
| 604 | private void fileExit() { | |
| 605 | final Window window = getWindow(); | |
| 606 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 607 | } | |
| 608 | ||
| 609 | //---- R menu actions | |
| 610 | private void rScript() { | |
| 611 | final String script = getPreferences().get( PERSIST_R_STARTUP, "" ); | |
| 612 | final RScriptDialog dialog = new RScriptDialog( | |
| 613 | getWindow(), "Dialog.r.script.title", script ); | |
| 614 | final Optional<String> result = dialog.showAndWait(); | |
| 615 | ||
| 616 | result.ifPresent( this::putStartupScript ); | |
| 617 | } | |
| 618 | ||
| 619 | private void rDirectory() { | |
| 620 | final TextInputDialog dialog = new TextInputDialog( | |
| 621 | getPreferences().get( PERSIST_R_DIRECTORY, USER_DIRECTORY ) | |
| 622 | ); | |
| 623 | ||
| 624 | dialog.setTitle( get( "Dialog.r.directory.title" ) ); | |
| 625 | dialog.setHeaderText( getLiteral( "Dialog.r.directory.header" ) ); | |
| 626 | dialog.setContentText( "Directory" ); | |
| 627 | ||
| 628 | final Optional<String> result = dialog.showAndWait(); | |
| 629 | ||
| 630 | result.ifPresent( this::putStartupDirectory ); | |
| 631 | } | |
| 632 | ||
| 633 | /** | |
| 634 | * Stores the R startup script into the user preferences. | |
| 635 | */ | |
| 636 | private void putStartupScript( final String script ) { | |
| 637 | putPreference( PERSIST_R_STARTUP, script ); | |
| 638 | } | |
| 639 | ||
| 640 | /** | |
| 641 | * Stores the R bootstrap script directory into the user preferences. | |
| 642 | */ | |
| 643 | private void putStartupDirectory( final String directory ) { | |
| 644 | putPreference( PERSIST_R_DIRECTORY, directory ); | |
| 645 | } | |
| 646 | ||
| 647 | //---- Help actions ------------------------------------------------------- | |
| 648 | private void helpAbout() { | |
| 649 | final Alert alert = new Alert( AlertType.INFORMATION ); | |
| 650 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 651 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 652 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 653 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 654 | alert.initOwner( getWindow() ); | |
| 655 | ||
| 656 | alert.showAndWait(); | |
| 657 | } | |
| 658 | ||
| 659 | //---- Convenience accessors ---------------------------------------------- | |
| 660 | private float getFloat( final String key, final float defaultValue ) { | |
| 661 | return getPreferences().getFloat( key, defaultValue ); | |
| 662 | } | |
| 663 | ||
| 664 | private Preferences getPreferences() { | |
| 665 | return getOptions().getState(); | |
| 666 | } | |
| 667 | ||
| 668 | protected Scene getScene() { | |
| 669 | return mScene; | |
| 670 | } | |
| 671 | ||
| 672 | public Window getWindow() { | |
| 673 | return getScene().getWindow(); | |
| 674 | } | |
| 675 | ||
| 676 | private MarkdownEditorPane getActiveEditor() { | |
| 677 | final EditorPane pane = getActiveFileEditor().getEditorPane(); | |
| 678 | ||
| 679 | return pane instanceof MarkdownEditorPane | |
| 680 | ? (MarkdownEditorPane) pane | |
| 681 | : null; | |
| 682 | } | |
| 683 | ||
| 684 | private FileEditorTab getActiveFileEditor() { | |
| 685 | return getFileEditorPane().getActiveFileEditor(); | |
| 686 | } | |
| 687 | ||
| 688 | //---- Member accessors --------------------------------------------------- | |
| 689 | ||
| 690 | private Map<FileEditorTab, Processor<String>> getProcessors() { | |
| 691 | return mProcessors; | |
| 692 | } | |
| 693 | ||
| 694 | private FileEditorTabPane getFileEditorPane() { | |
| 695 | if( this.fileEditorPane == null ) { | |
| 696 | this.fileEditorPane = createFileEditorPane(); | |
| 697 | } | |
| 698 | ||
| 699 | return this.fileEditorPane; | |
| 700 | } | |
| 701 | ||
| 702 | private HTMLPreviewPane getPreviewPane() { | |
| 703 | return mPreviewPane; | |
| 704 | } | |
| 705 | ||
| 706 | private void setDefinitionSource( final DefinitionSource definitionSource ) { | |
| 707 | assert definitionSource != null; | |
| 708 | mDefinitionSource = definitionSource; | |
| 709 | } | |
| 710 | ||
| 711 | private DefinitionSource getDefinitionSource() { | |
| 712 | return mDefinitionSource; | |
| 713 | } | |
| 714 | ||
| 715 | private DefinitionPane getDefinitionPane() { | |
| 716 | return mDefinitionPane; | |
| 717 | } | |
| 718 | ||
| 719 | private Options getOptions() { | |
| 720 | return mOptions; | |
| 721 | } | |
| 722 | ||
| 723 | private Snitch getSnitch() { | |
| 724 | return mSnitch; | |
| 725 | } | |
| 726 | ||
| 727 | private Notifier getNotifier() { | |
| 728 | return mNotifier; | |
| 729 | } | |
| 730 | ||
| 731 | private Text getLineNumberText() { | |
| 732 | return mLineNumberText; | |
| 733 | } | |
| 734 | ||
| 735 | private StatusBar getStatusBar() { | |
| 736 | return mStatusBar; | |
| 737 | } | |
| 738 | ||
| 739 | private TextField getFindTextField() { | |
| 740 | return mFindTextField; | |
| 741 | } | |
| 742 | ||
| 743 | //---- Member creators ---------------------------------------------------- | |
| 744 | ||
| 745 | /** | |
| 746 | * Factory to create processors that are suited to different file types. | |
| 747 | * | |
| 748 | * @param tab The tab that is subjected to processing. | |
| 749 | * @return A processor suited to the file type specified by the tab's path. | |
| 750 | */ | |
| 751 | private Processor<String> createProcessor( final FileEditorTab tab ) { | |
| 752 | return createProcessorFactory().createProcessor( tab ); | |
| 753 | } | |
| 754 | ||
| 755 | private ProcessorFactory createProcessorFactory() { | |
| 756 | return new ProcessorFactory( getPreviewPane(), getResolvedMap() ); | |
| 757 | } | |
| 758 | ||
| 759 | private DefinitionSource createDefaultDefinitionSource() { | |
| 760 | return new YamlDefinitionSource( getDefinitionPath() ); | |
| 761 | } | |
| 762 | ||
| 763 | private DefinitionSource createDefinitionSource( final Path path ) { | |
| 764 | try { | |
| 765 | return createDefinitionFactory().createDefinitionSource( path ); | |
| 766 | } catch( final Exception ex ) { | |
| 767 | error( ex ); | |
| 768 | return createDefaultDefinitionSource(); | |
| 769 | } | |
| 770 | } | |
| 771 | ||
| 772 | private TextField createFindTextField() { | |
| 773 | return new TextField(); | |
| 774 | } | |
| 775 | ||
| 776 | /** | |
| 777 | * Create an editor pane to hold file editor tabs. | |
| 778 | * | |
| 779 | * @return A new instance, never null. | |
| 780 | */ | |
| 781 | private FileEditorTabPane createFileEditorPane() { | |
| 782 | return new FileEditorTabPane(); | |
| 783 | } | |
| 784 | ||
| 785 | private DefinitionFactory createDefinitionFactory() { | |
| 786 | return new DefinitionFactory(); | |
| 787 | } | |
| 788 | ||
| 789 | private StatusBar createStatusBar() { | |
| 790 | return new StatusBar(); | |
| 791 | } | |
| 792 | ||
| 793 | private Scene createScene() { | |
| 794 | final SplitPane splitPane = new SplitPane( | |
| 795 | getDefinitionPane().getNode(), | |
| 796 | getFileEditorPane().getNode(), | |
| 797 | getPreviewPane().getNode() ); | |
| 798 | ||
| 799 | splitPane.setDividerPositions( | |
| 800 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 801 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 802 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 803 | ||
| 804 | getDefinitionPane().prefHeightProperty().bind( splitPane.heightProperty() ); | |
| 805 | ||
| 806 | final BorderPane borderPane = new BorderPane(); | |
| 807 | borderPane.setPrefSize( 1024, 800 ); | |
| 808 | borderPane.setTop( createMenuBar() ); | |
| 809 | borderPane.setBottom( getStatusBar() ); | |
| 810 | borderPane.setCenter( splitPane ); | |
| 811 | ||
| 812 | final VBox statusBar = new VBox(); | |
| 813 | statusBar.setAlignment( Pos.BASELINE_CENTER ); | |
| 814 | statusBar.getChildren().add( getLineNumberText() ); | |
| 815 | getStatusBar().getRightItems().add( statusBar ); | |
| 816 | ||
| 817 | return new Scene( borderPane ); | |
| 818 | } | |
| 819 | ||
| 820 | private Text createLineNumberText() { | |
| 821 | return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) ); | |
| 822 | } | |
| 823 | ||
| 824 | private Node createMenuBar() { | |
| 825 | final BooleanBinding activeFileEditorIsNull = | |
| 826 | getFileEditorPane().activeFileEditorProperty() | |
| 827 | .isNull(); | |
| 828 | ||
| 829 | // File actions | |
| 830 | final Action fileNewAction = new Action( get( "Main.menu.file.new" ), | |
| 831 | "Shortcut+N", FILE_ALT, | |
| 832 | e -> fileNew() ); | |
| 833 | final Action fileOpenAction = new Action( get( "Main.menu.file.open" ), | |
| 834 | "Shortcut+O", FOLDER_OPEN_ALT, | |
| 835 | e -> fileOpen() ); | |
| 836 | final Action fileCloseAction = new Action( get( "Main.menu.file.close" ), | |
| 837 | "Shortcut+W", null, | |
| 838 | e -> fileClose(), | |
| 839 | activeFileEditorIsNull ); | |
| 840 | final Action fileCloseAllAction = new Action( get( | |
| 841 | "Main.menu.file.close_all" ), null, null, e -> fileCloseAll(), | |
| 842 | activeFileEditorIsNull ); | |
| 843 | final Action fileSaveAction = new Action( get( "Main.menu.file.save" ), | |
| 844 | "Shortcut+S", FLOPPY_ALT, | |
| 845 | e -> fileSave(), | |
| 846 | createActiveBooleanProperty( | |
| 847 | FileEditorTab::modifiedProperty ) | |
| 848 | .not() ); | |
| 849 | final Action fileSaveAsAction = new Action( Messages.get( | |
| 850 | "Main.menu.file.save_as" ), null, null, e -> fileSaveAs(), | |
| 851 | activeFileEditorIsNull ); | |
| 852 | final Action fileSaveAllAction = new Action( | |
| 853 | get( "Main.menu.file.save_all" ), "Shortcut+Shift+S", null, | |
| 854 | e -> fileSaveAll(), | |
| 855 | Bindings.not( getFileEditorPane().anyFileEditorModifiedProperty() ) ); | |
| 856 | final Action fileExitAction = new Action( get( "Main.menu.file.exit" ), | |
| 857 | null, | |
| 858 | null, | |
| 859 | e -> fileExit() ); | |
| 860 | ||
| 861 | // Edit actions | |
| 862 | final Action editUndoAction = new Action( get( "Main.menu.edit.undo" ), | |
| 863 | "Shortcut+Z", UNDO, | |
| 864 | e -> getActiveEditor().undo(), | |
| 865 | createActiveBooleanProperty( | |
| 866 | FileEditorTab::canUndoProperty ) | |
| 867 | .not() ); | |
| 868 | final Action editRedoAction = new Action( get( "Main.menu.edit.redo" ), | |
| 869 | "Shortcut+Y", REPEAT, | |
| 870 | e -> getActiveEditor().redo(), | |
| 871 | createActiveBooleanProperty( | |
| 872 | FileEditorTab::canRedoProperty ) | |
| 873 | .not() ); | |
| 874 | final Action editFindAction = new Action( Messages.get( | |
| 875 | "Main.menu.edit.find" ), "Ctrl+F", SEARCH, | |
| 876 | e -> find(), | |
| 877 | activeFileEditorIsNull ); | |
| 878 | final Action editFindNextAction = new Action( Messages.get( | |
| 879 | "Main.menu.edit.find.next" ), "F3", null, | |
| 880 | e -> findNext(), | |
| 881 | activeFileEditorIsNull ); | |
| 882 | ||
| 883 | // Insert actions | |
| 884 | final Action insertBoldAction = new Action( get( "Main.menu.insert.bold" ), | |
| 885 | "Shortcut+B", BOLD, | |
| 886 | e -> getActiveEditor().surroundSelection( | |
| 887 | "**", "**" ), | |
| 888 | activeFileEditorIsNull ); | |
| 889 | final Action insertItalicAction = new Action( | |
| 890 | get( "Main.menu.insert.italic" ), "Shortcut+I", ITALIC, | |
| 891 | e -> getActiveEditor().surroundSelection( "*", "*" ), | |
| 892 | activeFileEditorIsNull ); | |
| 893 | final Action insertSuperscriptAction = new Action( get( | |
| 894 | "Main.menu.insert.superscript" ), "Shortcut+[", SUPERSCRIPT, | |
| 895 | e -> getActiveEditor().surroundSelection( | |
| 896 | "^", "^" ), | |
| 897 | activeFileEditorIsNull ); | |
| 898 | final Action insertSubscriptAction = new Action( get( | |
| 899 | "Main.menu.insert.subscript" ), "Shortcut+]", SUBSCRIPT, | |
| 900 | e -> getActiveEditor().surroundSelection( | |
| 901 | "~", "~" ), | |
| 902 | activeFileEditorIsNull ); | |
| 903 | final Action insertStrikethroughAction = new Action( get( | |
| 904 | "Main.menu.insert.strikethrough" ), "Shortcut+T", STRIKETHROUGH, | |
| 905 | e -> getActiveEditor().surroundSelection( | |
| 906 | "~~", "~~" ), | |
| 907 | activeFileEditorIsNull ); | |
| 908 | final Action insertBlockquoteAction = new Action( get( | |
| 909 | "Main.menu.insert.blockquote" ), | |
| 910 | "Ctrl+Q", | |
| 911 | QUOTE_LEFT, | |
| 912 | // not Shortcut+Q | |
| 913 | // because of conflict | |
| 914 | // on Mac | |
| 915 | e -> getActiveEditor().surroundSelection( | |
| 916 | "\n\n> ", "" ), | |
| 917 | activeFileEditorIsNull ); | |
| 918 | final Action insertCodeAction = new Action( get( "Main.menu.insert.code" ), | |
| 919 | "Shortcut+K", CODE, | |
| 920 | e -> getActiveEditor().surroundSelection( | |
| 921 | "`", "`" ), | |
| 922 | activeFileEditorIsNull ); | |
| 923 | final Action insertFencedCodeBlockAction = new Action( get( | |
| 924 | "Main.menu.insert.fenced_code_block" ), | |
| 925 | "Shortcut+Shift+K", | |
| 926 | FILE_CODE_ALT, | |
| 927 | e -> getActiveEditor() | |
| 928 | .surroundSelection( | |
| 929 | "\n\n```\n", | |
| 930 | "\n```\n\n", | |
| 931 | get( | |
| 932 | "Main.menu.insert.fenced_code_block.prompt" ) ), | |
| 933 | activeFileEditorIsNull ); | |
| 934 | ||
| 935 | final Action insertLinkAction = new Action( get( "Main.menu.insert.link" ), | |
| 936 | "Shortcut+L", LINK, | |
| 937 | e -> getActiveEditor().insertLink(), | |
| 938 | activeFileEditorIsNull ); | |
| 939 | final Action insertImageAction = new Action( get( "Main.menu.insert" + | |
| 940 | ".image" ), | |
| 941 | "Shortcut+G", PICTURE_ALT, | |
| 942 | e -> getActiveEditor().insertImage(), | |
| 943 | activeFileEditorIsNull ); | |
| 944 | ||
| 945 | // Number of header actions (H1 ... H3) | |
| 946 | final int HEADERS = 3; | |
| 947 | final Action[] headers = new Action[ HEADERS ]; | |
| 948 | ||
| 949 | for( int i = 1; i <= HEADERS; i++ ) { | |
| 950 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 951 | final String markup = String.format( "%n%n%s ", hashes ); | |
| 952 | final String text = get( "Main.menu.insert.header_" + i ); | |
| 953 | final String accelerator = "Shortcut+" + i; | |
| 954 | final String prompt = get( "Main.menu.insert.header_" + i + ".prompt" ); | |
| 955 | ||
| 956 | headers[ i - 1 ] = new Action( text, accelerator, HEADER, | |
| 957 | e -> getActiveEditor().surroundSelection( | |
| 958 | markup, "", prompt ), | |
| 959 | activeFileEditorIsNull ); | |
| 960 | } | |
| 961 | ||
| 962 | final Action insertUnorderedListAction = new Action( | |
| 963 | get( "Main.menu.insert.unordered_list" ), "Shortcut+U", LIST_UL, | |
| 964 | e -> getActiveEditor().surroundSelection( "\n\n* ", "" ), | |
| 965 | activeFileEditorIsNull ); | |
| 966 | final Action insertOrderedListAction = new Action( | |
| 967 | get( "Main.menu.insert.ordered_list" ), "Shortcut+Shift+O", LIST_OL, | |
| 968 | e -> getActiveEditor().surroundSelection( "\n\n1. ", "" ), | |
| 969 | activeFileEditorIsNull ); | |
| 970 | final Action insertHorizontalRuleAction = new Action( | |
| 971 | get( "Main.menu.insert.horizontal_rule" ), "Shortcut+H", null, | |
| 972 | e -> getActiveEditor().surroundSelection( "\n\n---\n\n", "" ), | |
| 973 | activeFileEditorIsNull ); | |
| 974 | ||
| 975 | // R actions | |
| 976 | final Action mRScriptAction = new Action( | |
| 977 | get( "Main.menu.r.script" ), null, null, e -> rScript() ); | |
| 978 | ||
| 979 | final Action mRDirectoryAction = new Action( | |
| 980 | get( "Main.menu.r.directory" ), null, null, e -> rDirectory() ); | |
| 981 | ||
| 982 | // Help actions | |
| 983 | final Action helpAboutAction = new Action( | |
| 984 | get( "Main.menu.help.about" ), null, null, e -> helpAbout() ); | |
| 985 | ||
| 986 | //---- MenuBar ---- | |
| 987 | final Menu fileMenu = ActionUtils.createMenu( | |
| 988 | get( "Main.menu.file" ), | |
| 989 | fileNewAction, | |
| 990 | fileOpenAction, | |
| 991 | null, | |
| 992 | fileCloseAction, | |
| 993 | fileCloseAllAction, | |
| 994 | null, | |
| 995 | fileSaveAction, | |
| 996 | fileSaveAsAction, | |
| 997 | fileSaveAllAction, | |
| 998 | null, | |
| 999 | fileExitAction ); | |
| 1000 | ||
| 1001 | final Menu editMenu = ActionUtils.createMenu( | |
| 1002 | get( "Main.menu.edit" ), | |
| 1003 | editUndoAction, | |
| 1004 | editRedoAction, | |
| 1005 | editFindAction, | |
| 1006 | editFindNextAction ); | |
| 1007 | ||
| 1008 | final Menu insertMenu = ActionUtils.createMenu( | |
| 1009 | get( "Main.menu.insert" ), | |
| 1010 | insertBoldAction, | |
| 1011 | insertItalicAction, | |
| 1012 | insertSuperscriptAction, | |
| 1013 | insertSubscriptAction, | |
| 1014 | insertStrikethroughAction, | |
| 1015 | insertBlockquoteAction, | |
| 1016 | insertCodeAction, | |
| 1017 | insertFencedCodeBlockAction, | |
| 1018 | null, | |
| 1019 | insertLinkAction, | |
| 1020 | insertImageAction, | |
| 1021 | null, | |
| 1022 | headers[ 0 ], | |
| 1023 | headers[ 1 ], | |
| 1024 | headers[ 2 ], | |
| 1025 | null, | |
| 1026 | insertUnorderedListAction, | |
| 1027 | insertOrderedListAction, | |
| 1028 | insertHorizontalRuleAction ); | |
| 1029 | ||
| 1030 | final Menu rMenu = ActionUtils.createMenu( | |
| 1031 | get( "Main.menu.r" ), | |
| 1032 | mRScriptAction, | |
| 1033 | mRDirectoryAction ); | |
| 1034 | ||
| 1035 | final Menu helpMenu = ActionUtils.createMenu( | |
| 1036 | get( "Main.menu.help" ), | |
| 1037 | helpAboutAction ); | |
| 1038 | ||
| 1039 | final MenuBar menuBar = new MenuBar( | |
| 1040 | fileMenu, | |
| 1041 | editMenu, | |
| 1042 | insertMenu, | |
| 1043 | rMenu, | |
| 1044 | helpMenu ); | |
| 1045 | ||
| 1046 | //---- ToolBar ---- | |
| 1047 | final ToolBar toolBar = ActionUtils.createToolBar( | |
| 1048 | fileNewAction, | |
| 1049 | fileOpenAction, | |
| 1050 | fileSaveAction, | |
| 1051 | null, | |
| 1052 | editUndoAction, | |
| 1053 | editRedoAction, | |
| 1054 | null, | |
| 1055 | insertBoldAction, | |
| 1056 | insertItalicAction, | |
| 1057 | insertSuperscriptAction, | |
| 1058 | insertSubscriptAction, | |
| 1059 | insertBlockquoteAction, | |
| 1060 | insertCodeAction, | |
| 1061 | insertFencedCodeBlockAction, | |
| 1062 | null, | |
| 1063 | insertLinkAction, | |
| 1064 | insertImageAction, | |
| 1065 | null, | |
| 1066 | headers[ 0 ], | |
| 1067 | null, | |
| 1068 | insertUnorderedListAction, | |
| 1069 | insertOrderedListAction ); | |
| 1070 | ||
| 1071 | return new VBox( menuBar, toolBar ); | |
| 1072 | } | |
| 1073 | ||
| 1074 | /** | |
| 1075 | * Creates a boolean property that is bound to another boolean value of the | |
| 1076 | * active editor. | |
| 1077 | */ | |
| 1078 | private BooleanProperty createActiveBooleanProperty( | |
| 1079 | final Function<FileEditorTab, ObservableBooleanValue> func ) { | |
| 1080 | ||
| 1081 | final BooleanProperty b = new SimpleBooleanProperty(); | |
| 1082 | final FileEditorTab tab = getActiveFileEditor(); | |
| 1083 | ||
| 1084 | if( tab != null ) { | |
| 1085 | b.bind( func.apply( tab ) ); | |
| 1086 | } | |
| 1087 | ||
| 1088 | getFileEditorPane().activeFileEditorProperty().addListener( | |
| 1089 | ( observable, oldFileEditor, newFileEditor ) -> { | |
| 1090 | b.unbind(); | |
| 1091 | ||
| 1092 | if( newFileEditor != null ) { | |
| 1093 | b.bind( func.apply( newFileEditor ) ); | |
| 1094 | } | |
| 1095 | else { | |
| 1096 | b.set( false ); | |
| 1097 | } | |
| 1098 | } | |
| 1099 | ); | |
| 1100 | ||
| 1101 | return b; | |
| 1102 | } | |
| 1103 | ||
| 1104 | private void initLayout() { | |
| 1105 | final Scene appScene = getScene(); | |
| 1106 | ||
| 1107 | appScene.getStylesheets().add( STYLESHEET_SCENE ); | |
| 1108 | ||
| 1109 | // TODO: Apply an XML syntax highlighting for XML files. | |
| 1110 | // appScene.getStylesheets().add( STYLESHEET_XML ); | |
| 1111 | appScene.windowProperty().addListener( | |
| 1112 | ( observable, oldWindow, newWindow ) -> | |
| 1113 | newWindow.setOnCloseRequest( | |
| 1114 | e -> { | |
| 1115 | if( !getFileEditorPane().closeAllEditors() ) { | |
| 1116 | e.consume(); | |
| 1117 | } | |
| 1118 | } | |
| 1119 | ) | |
| 1120 | ); | |
| 1121 | } | |
| 1122 | ||
| 1123 | private void putPreference( final String key, final String value ) { | |
| 1124 | try { | |
| 1125 | getPreferences().put( key, value ); | |
| 1126 | } catch( final Exception ex ) { | |
| 1127 | getNotifier().notify( ex ); | |
| 1128 | } | |
| 1129 | } | |
| 1130 | ||
| 1131 | /** | |
| 1132 | * Returns the value for a key from the settings properties file. | |
| 1133 | * | |
| 1134 | * @param key Key within the settings properties file to find. | |
| 1135 | * @param value Default value to return if the key is not found. | |
| 1136 | * @return The value for the given key from the settings file, or the | |
| 1137 | * given {@code value} if no key found. | |
| 1138 | */ | |
| 1139 | @SuppressWarnings("SameParameterValue") | |
| 1140 | private String getSetting( final String key, final String value ) { | |
| 1141 | return mSettings.getSetting( key, value ); | |
| 35 | import com.scrivenvar.editors.EditorPane; | |
| 36 | import com.scrivenvar.editors.VariableNameInjector; | |
| 37 | import com.scrivenvar.editors.markdown.MarkdownEditorPane; | |
| 38 | import com.scrivenvar.preferences.UserPreferences; | |
| 39 | import com.scrivenvar.preview.HTMLPreviewPane; | |
| 40 | import com.scrivenvar.processors.Processor; | |
| 41 | import com.scrivenvar.processors.ProcessorFactory; | |
| 42 | import com.scrivenvar.service.Options; | |
| 43 | import com.scrivenvar.service.Snitch; | |
| 44 | import com.scrivenvar.service.events.Notifier; | |
| 45 | import com.scrivenvar.util.Action; | |
| 46 | import com.scrivenvar.util.ActionBuilder; | |
| 47 | import com.scrivenvar.util.ActionUtils; | |
| 48 | import javafx.application.Platform; | |
| 49 | import javafx.beans.binding.Bindings; | |
| 50 | import javafx.beans.binding.BooleanBinding; | |
| 51 | import javafx.beans.property.BooleanProperty; | |
| 52 | import javafx.beans.property.SimpleBooleanProperty; | |
| 53 | import javafx.beans.value.ObservableBooleanValue; | |
| 54 | import javafx.beans.value.ObservableValue; | |
| 55 | import javafx.collections.ListChangeListener.Change; | |
| 56 | import javafx.collections.ObservableList; | |
| 57 | import javafx.event.Event; | |
| 58 | import javafx.event.EventHandler; | |
| 59 | import javafx.geometry.Pos; | |
| 60 | import javafx.scene.Node; | |
| 61 | import javafx.scene.Scene; | |
| 62 | import javafx.scene.control.*; | |
| 63 | import javafx.scene.control.Alert.AlertType; | |
| 64 | import javafx.scene.image.Image; | |
| 65 | import javafx.scene.image.ImageView; | |
| 66 | import javafx.scene.input.KeyEvent; | |
| 67 | import javafx.scene.layout.BorderPane; | |
| 68 | import javafx.scene.layout.VBox; | |
| 69 | import javafx.scene.text.Text; | |
| 70 | import javafx.stage.Window; | |
| 71 | import javafx.stage.WindowEvent; | |
| 72 | import javafx.util.Duration; | |
| 73 | import org.controlsfx.control.StatusBar; | |
| 74 | import org.fxmisc.richtext.model.TwoDimensional.Position; | |
| 75 | ||
| 76 | import java.io.File; | |
| 77 | import java.nio.file.Path; | |
| 78 | import java.util.HashMap; | |
| 79 | import java.util.Map; | |
| 80 | import java.util.Observable; | |
| 81 | import java.util.Observer; | |
| 82 | import java.util.concurrent.atomic.AtomicInteger; | |
| 83 | import java.util.function.Consumer; | |
| 84 | import java.util.function.Function; | |
| 85 | import java.util.prefs.Preferences; | |
| 86 | ||
| 87 | import static com.scrivenvar.Constants.*; | |
| 88 | import static com.scrivenvar.Messages.get; | |
| 89 | import static com.scrivenvar.util.StageState.*; | |
| 90 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.*; | |
| 91 | import static javafx.event.Event.fireEvent; | |
| 92 | import static javafx.scene.input.KeyCode.ENTER; | |
| 93 | import static javafx.scene.input.KeyCode.TAB; | |
| 94 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 95 | ||
| 96 | /** | |
| 97 | * Main window containing a tab pane in the center for file editors. | |
| 98 | * | |
| 99 | * @author Karl Tauber and White Magic Software, Ltd. | |
| 100 | */ | |
| 101 | public class MainWindow implements Observer { | |
| 102 | ||
| 103 | /** | |
| 104 | * The {@code OPTIONS} variable must be declared before all other variables | |
| 105 | * to prevent subsequent initializations from failing due to missing user | |
| 106 | * preferences. | |
| 107 | */ | |
| 108 | private final static Options OPTIONS = Services.load( Options.class ); | |
| 109 | private final static Snitch SNITCH = Services.load( Snitch.class ); | |
| 110 | private final static Notifier NOTIFIER = Services.load( Notifier.class ); | |
| 111 | ||
| 112 | private final Scene mScene; | |
| 113 | private final StatusBar mStatusBar; | |
| 114 | private final Text mLineNumberText; | |
| 115 | private final TextField mFindTextField; | |
| 116 | ||
| 117 | private DefinitionSource mDefinitionSource = createDefaultDefinitionSource(); | |
| 118 | private final DefinitionPane mDefinitionPane = new DefinitionPane(); | |
| 119 | private final HTMLPreviewPane mPreviewPane = createHTMLPreviewPane(); | |
| 120 | private FileEditorTabPane fileEditorPane; | |
| 121 | ||
| 122 | /** | |
| 123 | * Prevents re-instantiation of processing classes. | |
| 124 | */ | |
| 125 | private final Map<FileEditorTab, Processor<String>> mProcessors = | |
| 126 | new HashMap<>(); | |
| 127 | ||
| 128 | private final Map<String, String> mResolvedMap = | |
| 129 | new HashMap<>( DEFAULT_MAP_SIZE ); | |
| 130 | ||
| 131 | /** | |
| 132 | * Listens on the definition pane for double-click events. | |
| 133 | */ | |
| 134 | private VariableNameInjector variableNameInjector; | |
| 135 | ||
| 136 | /** | |
| 137 | * Called when the definition data is changed. | |
| 138 | */ | |
| 139 | private final EventHandler<TreeItem.TreeModificationEvent<Event>> | |
| 140 | mTreeHandler = event -> { | |
| 141 | exportDefinitions( getDefinitionPath() ); | |
| 142 | interpolateResolvedMap(); | |
| 143 | refreshActiveTab(); | |
| 144 | }; | |
| 145 | ||
| 146 | /** | |
| 147 | * Called to inject the selected item when the user presses ENTER in the | |
| 148 | * definition pane. | |
| 149 | */ | |
| 150 | private final EventHandler<? super KeyEvent> mDefinitionKeyHandler = | |
| 151 | event -> { | |
| 152 | if( event.getCode() == ENTER ) { | |
| 153 | getVariableNameInjector().injectSelectedItem(); | |
| 154 | } | |
| 155 | }; | |
| 156 | ||
| 157 | /** | |
| 158 | * Called to switch to the definition pane when the user presses TAB. | |
| 159 | */ | |
| 160 | private final EventHandler<? super KeyEvent> mEditorKeyHandler = | |
| 161 | (EventHandler<KeyEvent>) event -> { | |
| 162 | if( event.getCode() == TAB ) { | |
| 163 | getDefinitionPane().requestFocus(); | |
| 164 | event.consume(); | |
| 165 | } | |
| 166 | }; | |
| 167 | ||
| 168 | private final Object mMutex = new Object(); | |
| 169 | private final AtomicInteger mScrollRatio = new AtomicInteger( 0 ); | |
| 170 | ||
| 171 | /** | |
| 172 | * Called to synchronize the scrolling areas. | |
| 173 | */ | |
| 174 | private final Consumer<Double> mScrollEventObserver = o -> { | |
| 175 | final var eScrollPane = getActiveEditor().getScrollPane(); | |
| 176 | final int eScrollY = | |
| 177 | eScrollPane.estimatedScrollYProperty().getValue().intValue(); | |
| 178 | final int eHeight = (int) | |
| 179 | (eScrollPane.totalHeightEstimateProperty().getValue().intValue() | |
| 180 | - eScrollPane.getHeight()); | |
| 181 | final double eRatio = eHeight > 0 | |
| 182 | ? Math.min( Math.max( eScrollY / (float) eHeight, 0 ), 1 ) : 0; | |
| 183 | ||
| 184 | final var pPreviewPane = getPreviewPane(); | |
| 185 | final var pScrollBar = pPreviewPane.getVerticalScrollBar(); | |
| 186 | final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight(); | |
| 187 | final var pScrollY = (int) (pHeight * eRatio); | |
| 188 | final var pScrollPane = pPreviewPane.getScrollPane(); | |
| 189 | ||
| 190 | final int oldScrollY = mScrollRatio.getAndSet( pScrollY ); | |
| 191 | final int delta = Math.abs( oldScrollY - pScrollY ); | |
| 192 | ||
| 193 | if( delta > 33 ) { | |
| 194 | // Prevent concurrent modification exceptions when attempting to | |
| 195 | // set the vertical scroll bar position. | |
| 196 | synchronized( mMutex ) { | |
| 197 | Platform.runLater( () -> { | |
| 198 | pScrollBar.setValue( pScrollY ); | |
| 199 | pScrollPane.repaint(); | |
| 200 | } ); | |
| 201 | } | |
| 202 | } | |
| 203 | }; | |
| 204 | ||
| 205 | public MainWindow() { | |
| 206 | mStatusBar = createStatusBar(); | |
| 207 | mLineNumberText = createLineNumberText(); | |
| 208 | mFindTextField = createFindTextField(); | |
| 209 | mScene = createScene(); | |
| 210 | ||
| 211 | initLayout(); | |
| 212 | initFindInput(); | |
| 213 | initSnitch(); | |
| 214 | initDefinitionListener(); | |
| 215 | initTabAddedListener(); | |
| 216 | initTabChangedListener(); | |
| 217 | restorePreferences(); | |
| 218 | } | |
| 219 | ||
| 220 | private void initLayout() { | |
| 221 | final Scene appScene = getScene(); | |
| 222 | ||
| 223 | appScene.getStylesheets().add( STYLESHEET_SCENE ); | |
| 224 | ||
| 225 | // TODO: Apply an XML syntax highlighting for XML files. | |
| 226 | // appScene.getStylesheets().add( STYLESHEET_XML ); | |
| 227 | appScene.windowProperty().addListener( | |
| 228 | ( observable, oldWindow, newWindow ) -> | |
| 229 | newWindow.setOnCloseRequest( | |
| 230 | e -> { | |
| 231 | if( !getFileEditorPane().closeAllEditors() ) { | |
| 232 | e.consume(); | |
| 233 | } | |
| 234 | } | |
| 235 | ) | |
| 236 | ); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key | |
| 241 | * presses. | |
| 242 | */ | |
| 243 | private void initFindInput() { | |
| 244 | final TextField input = getFindTextField(); | |
| 245 | ||
| 246 | input.setOnKeyPressed( ( KeyEvent event ) -> { | |
| 247 | switch( event.getCode() ) { | |
| 248 | case F3: | |
| 249 | case ENTER: | |
| 250 | editFindNext(); | |
| 251 | break; | |
| 252 | case F: | |
| 253 | if( !event.isControlDown() ) { | |
| 254 | break; | |
| 255 | } | |
| 256 | case ESCAPE: | |
| 257 | getStatusBar().setGraphic( null ); | |
| 258 | getActiveFileEditor().getEditorPane().requestFocus(); | |
| 259 | break; | |
| 260 | } | |
| 261 | } ); | |
| 262 | ||
| 263 | // Remove when the input field loses focus. | |
| 264 | input.focusedProperty().addListener( | |
| 265 | ( | |
| 266 | final ObservableValue<? extends Boolean> focused, | |
| 267 | final Boolean oFocus, | |
| 268 | final Boolean nFocus ) -> { | |
| 269 | if( !nFocus ) { | |
| 270 | getStatusBar().setGraphic( null ); | |
| 271 | } | |
| 272 | } | |
| 273 | ); | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Watch for changes to external files. In particular, this awaits | |
| 278 | * modifications to any XSL files associated with XML files being edited. When | |
| 279 | * an XSL file is modified (external to the application), the snitch's ears | |
| 280 | * perk up and the file is reloaded. This keeps the XSL transformation up to | |
| 281 | * date with what's on the file system. | |
| 282 | */ | |
| 283 | private void initSnitch() { | |
| 284 | SNITCH.addObserver( this ); | |
| 285 | } | |
| 286 | ||
| 287 | /** | |
| 288 | * Listen for {@link FileEditorTabPane} to receive open definition file event. | |
| 289 | */ | |
| 290 | private void initDefinitionListener() { | |
| 291 | getFileEditorPane().onOpenDefinitionFileProperty().addListener( | |
| 292 | ( final ObservableValue<? extends Path> file, | |
| 293 | final Path oldPath, final Path newPath ) -> { | |
| 294 | // Indirectly refresh the resolved map. | |
| 295 | resetProcessors(); | |
| 296 | ||
| 297 | openDefinitions( newPath ); | |
| 298 | ||
| 299 | // Will create new processors and therefore a new resolved map. | |
| 300 | refreshActiveTab(); | |
| 301 | } | |
| 302 | ); | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * When tabs are added, hook the various change listeners onto the new tab so | |
| 307 | * that the preview pane refreshes as necessary. | |
| 308 | */ | |
| 309 | private void initTabAddedListener() { | |
| 310 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 311 | ||
| 312 | // Make sure the text processor kicks off when new files are opened. | |
| 313 | final ObservableList<Tab> tabs = editorPane.getTabs(); | |
| 314 | ||
| 315 | // Update the preview pane on tab changes. | |
| 316 | tabs.addListener( | |
| 317 | ( final Change<? extends Tab> change ) -> { | |
| 318 | while( change.next() ) { | |
| 319 | if( change.wasAdded() ) { | |
| 320 | // Multiple tabs can be added simultaneously. | |
| 321 | for( final Tab newTab : change.getAddedSubList() ) { | |
| 322 | final FileEditorTab tab = (FileEditorTab) newTab; | |
| 323 | ||
| 324 | initTextChangeListener( tab ); | |
| 325 | initKeyboardEventListeners( tab ); | |
| 326 | // initSyntaxListener( tab ); | |
| 327 | } | |
| 328 | } | |
| 329 | } | |
| 330 | } | |
| 331 | ); | |
| 332 | } | |
| 333 | ||
| 334 | /** | |
| 335 | * Listen for new tab selection events. | |
| 336 | */ | |
| 337 | private void initTabChangedListener() { | |
| 338 | final FileEditorTabPane editorPane = getFileEditorPane(); | |
| 339 | ||
| 340 | // Update the preview pane changing tabs. | |
| 341 | editorPane.addTabSelectionListener( | |
| 342 | ( ObservableValue<? extends Tab> tabPane, | |
| 343 | final Tab oldTab, final Tab newTab ) -> { | |
| 344 | updateVariableNameInjector(); | |
| 345 | ||
| 346 | // If there was no old tab, then this is a first time load, which | |
| 347 | // can be ignored. | |
| 348 | if( oldTab != null ) { | |
| 349 | if( newTab == null ) { | |
| 350 | closeRemainingTab(); | |
| 351 | } | |
| 352 | else { | |
| 353 | // Update the preview with the edited text. | |
| 354 | refreshSelectedTab( (FileEditorTab) newTab ); | |
| 355 | } | |
| 356 | } | |
| 357 | } | |
| 358 | ); | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * Reloads the preferences from the previous session. | |
| 363 | */ | |
| 364 | private void restorePreferences() { | |
| 365 | restoreDefinitionPane(); | |
| 366 | getFileEditorPane().restorePreferences(); | |
| 367 | } | |
| 368 | ||
| 369 | /** | |
| 370 | * Ensure that the keyboard events are received when a new tab is added | |
| 371 | * to the user interface. | |
| 372 | * | |
| 373 | * @param tab The tab that can trigger keyboard events, such as control+space. | |
| 374 | */ | |
| 375 | private void initKeyboardEventListeners( final FileEditorTab tab ) { | |
| 376 | final VariableNameInjector vin = getVariableNameInjector(); | |
| 377 | vin.initKeyboardEventListeners( tab ); | |
| 378 | ||
| 379 | tab.addEventFilter( KeyEvent.KEY_PRESSED, mEditorKeyHandler ); | |
| 380 | } | |
| 381 | ||
| 382 | private void initTextChangeListener( final FileEditorTab tab ) { | |
| 383 | tab.addTextChangeListener( | |
| 384 | ( ObservableValue<? extends String> editor, | |
| 385 | final String oldValue, final String newValue ) -> | |
| 386 | refreshSelectedTab( tab ) | |
| 387 | ); | |
| 388 | } | |
| 389 | ||
| 390 | private void updateVariableNameInjector() { | |
| 391 | getVariableNameInjector().setFileEditorTab( getActiveFileEditor() ); | |
| 392 | } | |
| 393 | ||
| 394 | private void setVariableNameInjector( final VariableNameInjector injector ) { | |
| 395 | this.variableNameInjector = injector; | |
| 396 | } | |
| 397 | ||
| 398 | private synchronized VariableNameInjector getVariableNameInjector() { | |
| 399 | if( this.variableNameInjector == null ) { | |
| 400 | final VariableNameInjector vin = createVariableNameInjector(); | |
| 401 | setVariableNameInjector( vin ); | |
| 402 | } | |
| 403 | ||
| 404 | return this.variableNameInjector; | |
| 405 | } | |
| 406 | ||
| 407 | private VariableNameInjector createVariableNameInjector() { | |
| 408 | final FileEditorTab tab = getActiveFileEditor(); | |
| 409 | final DefinitionPane pane = getDefinitionPane(); | |
| 410 | ||
| 411 | return new VariableNameInjector( tab, pane ); | |
| 412 | } | |
| 413 | ||
| 414 | /** | |
| 415 | * Called whenever the preview pane becomes out of sync with the file editor | |
| 416 | * tab. This can be called when the text changes, the caret paragraph changes, | |
| 417 | * or the file tab changes. | |
| 418 | * | |
| 419 | * @param tab The file editor tab that has been changed in some fashion. | |
| 420 | */ | |
| 421 | private void refreshSelectedTab( final FileEditorTab tab ) { | |
| 422 | if( tab == null ) { | |
| 423 | return; | |
| 424 | } | |
| 425 | ||
| 426 | getPreviewPane().setPath( tab.getPath() ); | |
| 427 | ||
| 428 | // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29 | |
| 429 | final Position p = tab.getCaretOffset(); | |
| 430 | getLineNumberText().setText( | |
| 431 | get( STATUS_BAR_LINE, | |
| 432 | p.getMajor() + 1, | |
| 433 | p.getMinor() + 1, | |
| 434 | tab.getCaretPosition() + 1 | |
| 435 | ) | |
| 436 | ); | |
| 437 | ||
| 438 | Processor<String> processor = getProcessors().get( tab ); | |
| 439 | ||
| 440 | if( processor == null ) { | |
| 441 | processor = createProcessor( tab ); | |
| 442 | getProcessors().put( tab, processor ); | |
| 443 | } | |
| 444 | ||
| 445 | try { | |
| 446 | processor.processChain( tab.getEditorText() ); | |
| 447 | } catch( final Exception ex ) { | |
| 448 | error( ex ); | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 452 | private void refreshActiveTab() { | |
| 453 | refreshSelectedTab( getActiveFileEditor() ); | |
| 454 | } | |
| 455 | ||
| 456 | /** | |
| 457 | * Called when a definition source is opened. | |
| 458 | * | |
| 459 | * @param path Path to the definition source that was opened. | |
| 460 | */ | |
| 461 | private void openDefinitions( final Path path ) { | |
| 462 | try { | |
| 463 | final DefinitionSource ds = createDefinitionSource( path ); | |
| 464 | setDefinitionSource( ds ); | |
| 465 | getUserPreferences().definitionPathProperty().setValue( path.toFile() ); | |
| 466 | getUserPreferences().save(); | |
| 467 | ||
| 468 | final Tooltip tooltipPath = new Tooltip( path.toString() ); | |
| 469 | tooltipPath.setShowDelay( Duration.millis( 200 ) ); | |
| 470 | ||
| 471 | final DefinitionPane pane = getDefinitionPane(); | |
| 472 | pane.update( ds ); | |
| 473 | pane.addTreeChangeHandler( mTreeHandler ); | |
| 474 | pane.addKeyEventHandler( mDefinitionKeyHandler ); | |
| 475 | pane.filenameProperty().setValue( path.getFileName().toString() ); | |
| 476 | pane.setTooltip( tooltipPath ); | |
| 477 | ||
| 478 | interpolateResolvedMap(); | |
| 479 | } catch( final Exception e ) { | |
| 480 | error( e ); | |
| 481 | } | |
| 482 | } | |
| 483 | ||
| 484 | private void exportDefinitions( final Path path ) { | |
| 485 | try { | |
| 486 | final DefinitionPane pane = getDefinitionPane(); | |
| 487 | final TreeItem<String> root = pane.getTreeView().getRoot(); | |
| 488 | final TreeItem<String> problemChild = pane.isTreeWellFormed(); | |
| 489 | ||
| 490 | if( problemChild == null ) { | |
| 491 | getDefinitionSource().getTreeAdapter().export( root, path ); | |
| 492 | getNotifier().clear(); | |
| 493 | } | |
| 494 | else { | |
| 495 | final String msg = get( "yaml.error.tree.form", | |
| 496 | problemChild.getValue() ); | |
| 497 | getNotifier().notify( msg ); | |
| 498 | } | |
| 499 | } catch( final Exception e ) { | |
| 500 | error( e ); | |
| 501 | } | |
| 502 | } | |
| 503 | ||
| 504 | private void interpolateResolvedMap() { | |
| 505 | final Map<String, String> treeMap = getDefinitionPane().toMap(); | |
| 506 | final Map<String, String> map = new HashMap<>( treeMap ); | |
| 507 | MapInterpolator.interpolate( map ); | |
| 508 | ||
| 509 | getResolvedMap().clear(); | |
| 510 | getResolvedMap().putAll( map ); | |
| 511 | } | |
| 512 | ||
| 513 | private void restoreDefinitionPane() { | |
| 514 | openDefinitions( getDefinitionPath() ); | |
| 515 | } | |
| 516 | ||
| 517 | /** | |
| 518 | * Called when the last open tab is closed to clear the preview pane. | |
| 519 | */ | |
| 520 | private void closeRemainingTab() { | |
| 521 | getPreviewPane().clear(); | |
| 522 | } | |
| 523 | ||
| 524 | /** | |
| 525 | * Called when an exception occurs that warrants the user's attention. | |
| 526 | * | |
| 527 | * @param e The exception with a message that the user should know about. | |
| 528 | */ | |
| 529 | private void error( final Exception e ) { | |
| 530 | getNotifier().notify( e ); | |
| 531 | } | |
| 532 | ||
| 533 | //---- File actions ------------------------------------------------------- | |
| 534 | ||
| 535 | /** | |
| 536 | * Called when an observable instance has changed. This is called by both the | |
| 537 | * snitch service and the notify service. The snitch service can be called for | |
| 538 | * different file types, including definition sources. | |
| 539 | * | |
| 540 | * @param observable The observed instance. | |
| 541 | * @param value The noteworthy item. | |
| 542 | */ | |
| 543 | @Override | |
| 544 | public void update( final Observable observable, final Object value ) { | |
| 545 | if( value != null ) { | |
| 546 | if( observable instanceof Snitch && value instanceof Path ) { | |
| 547 | updateSelectedTab(); | |
| 548 | } | |
| 549 | else if( observable instanceof Notifier && value instanceof String ) { | |
| 550 | updateStatusBar( (String) value ); | |
| 551 | } | |
| 552 | } | |
| 553 | } | |
| 554 | ||
| 555 | /** | |
| 556 | * Updates the status bar to show the given message. | |
| 557 | * | |
| 558 | * @param s The message to show in the status bar. | |
| 559 | */ | |
| 560 | private void updateStatusBar( final String s ) { | |
| 561 | Platform.runLater( | |
| 562 | () -> { | |
| 563 | final int index = s.indexOf( '\n' ); | |
| 564 | final String message = s.substring( | |
| 565 | 0, index > 0 ? index : s.length() ); | |
| 566 | ||
| 567 | getStatusBar().setText( message ); | |
| 568 | } | |
| 569 | ); | |
| 570 | } | |
| 571 | ||
| 572 | /** | |
| 573 | * Called when a file has been modified. | |
| 574 | */ | |
| 575 | private void updateSelectedTab() { | |
| 576 | Platform.runLater( | |
| 577 | () -> { | |
| 578 | // Brute-force XSLT file reload by re-instantiating all processors. | |
| 579 | resetProcessors(); | |
| 580 | refreshActiveTab(); | |
| 581 | } | |
| 582 | ); | |
| 583 | } | |
| 584 | ||
| 585 | /** | |
| 586 | * After resetting the processors, they will refresh anew to be up-to-date | |
| 587 | * with the files (text and definition) currently loaded into the editor. | |
| 588 | */ | |
| 589 | private void resetProcessors() { | |
| 590 | getProcessors().clear(); | |
| 591 | } | |
| 592 | ||
| 593 | //---- File actions ------------------------------------------------------- | |
| 594 | ||
| 595 | private void fileNew() { | |
| 596 | getFileEditorPane().newEditor(); | |
| 597 | } | |
| 598 | ||
| 599 | private void fileOpen() { | |
| 600 | getFileEditorPane().openFileDialog(); | |
| 601 | } | |
| 602 | ||
| 603 | private void fileClose() { | |
| 604 | getFileEditorPane().closeEditor( getActiveFileEditor(), true ); | |
| 605 | } | |
| 606 | ||
| 607 | /** | |
| 608 | * TODO: Upon closing, first remove the tab change listeners. (There's no | |
| 609 | * need to re-render each tab when all are being closed.) | |
| 610 | */ | |
| 611 | private void fileCloseAll() { | |
| 612 | getFileEditorPane().closeAllEditors(); | |
| 613 | } | |
| 614 | ||
| 615 | private void fileSave() { | |
| 616 | getFileEditorPane().saveEditor( getActiveFileEditor() ); | |
| 617 | } | |
| 618 | ||
| 619 | private void fileSaveAs() { | |
| 620 | final FileEditorTab editor = getActiveFileEditor(); | |
| 621 | getFileEditorPane().saveEditorAs( editor ); | |
| 622 | getProcessors().remove( editor ); | |
| 623 | ||
| 624 | try { | |
| 625 | refreshSelectedTab( editor ); | |
| 626 | } catch( final Exception ex ) { | |
| 627 | getNotifier().notify( ex ); | |
| 628 | } | |
| 629 | } | |
| 630 | ||
| 631 | private void fileSaveAll() { | |
| 632 | getFileEditorPane().saveAllEditors(); | |
| 633 | } | |
| 634 | ||
| 635 | private void fileExit() { | |
| 636 | final Window window = getWindow(); | |
| 637 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 638 | } | |
| 639 | ||
| 640 | //---- Edit actions ------------------------------------------------------- | |
| 641 | ||
| 642 | /** | |
| 643 | * Used to find text in the active file editor window. | |
| 644 | */ | |
| 645 | private void editFind() { | |
| 646 | final TextField input = getFindTextField(); | |
| 647 | getStatusBar().setGraphic( input ); | |
| 648 | input.requestFocus(); | |
| 649 | } | |
| 650 | ||
| 651 | public void editFindNext() { | |
| 652 | getActiveFileEditor().searchNext( getFindTextField().getText() ); | |
| 653 | } | |
| 654 | ||
| 655 | public void editPreferences() { | |
| 656 | getUserPreferences().show(); | |
| 657 | } | |
| 658 | ||
| 659 | //---- Insert actions ----------------------------------------------------- | |
| 660 | ||
| 661 | /** | |
| 662 | * Delegates to the active editor to handle wrapping the current text | |
| 663 | * selection with leading and trailing strings. | |
| 664 | * | |
| 665 | * @param leading The string to put before the selection. | |
| 666 | * @param trailing The string to put after the selection. | |
| 667 | */ | |
| 668 | private void insertMarkdown( | |
| 669 | final String leading, final String trailing ) { | |
| 670 | getActiveEditor().surroundSelection( leading, trailing ); | |
| 671 | } | |
| 672 | ||
| 673 | @SuppressWarnings("SameParameterValue") | |
| 674 | private void insertMarkdown( | |
| 675 | final String leading, final String trailing, final String hint ) { | |
| 676 | getActiveEditor().surroundSelection( leading, trailing, hint ); | |
| 677 | } | |
| 678 | ||
| 679 | //---- Help actions ------------------------------------------------------- | |
| 680 | ||
| 681 | private void helpAbout() { | |
| 682 | final Alert alert = new Alert( AlertType.INFORMATION ); | |
| 683 | alert.setTitle( get( "Dialog.about.title" ) ); | |
| 684 | alert.setHeaderText( get( "Dialog.about.header" ) ); | |
| 685 | alert.setContentText( get( "Dialog.about.content" ) ); | |
| 686 | alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) ); | |
| 687 | alert.initOwner( getWindow() ); | |
| 688 | ||
| 689 | alert.showAndWait(); | |
| 690 | } | |
| 691 | ||
| 692 | //---- Member creators ---------------------------------------------------- | |
| 693 | ||
| 694 | /** | |
| 695 | * Factory to create processors that are suited to different file types. | |
| 696 | * | |
| 697 | * @param tab The tab that is subjected to processing. | |
| 698 | * @return A processor suited to the file type specified by the tab's path. | |
| 699 | */ | |
| 700 | private Processor<String> createProcessor( final FileEditorTab tab ) { | |
| 701 | return createProcessorFactory().createProcessor( tab ); | |
| 702 | } | |
| 703 | ||
| 704 | private ProcessorFactory createProcessorFactory() { | |
| 705 | return new ProcessorFactory( getPreviewPane(), getResolvedMap() ); | |
| 706 | } | |
| 707 | ||
| 708 | private HTMLPreviewPane createHTMLPreviewPane() { | |
| 709 | return new HTMLPreviewPane(); | |
| 710 | } | |
| 711 | ||
| 712 | private DefinitionSource createDefaultDefinitionSource() { | |
| 713 | return new YamlDefinitionSource( getDefinitionPath() ); | |
| 714 | } | |
| 715 | ||
| 716 | private DefinitionSource createDefinitionSource( final Path path ) { | |
| 717 | try { | |
| 718 | return createDefinitionFactory().createDefinitionSource( path ); | |
| 719 | } catch( final Exception ex ) { | |
| 720 | error( ex ); | |
| 721 | return createDefaultDefinitionSource(); | |
| 722 | } | |
| 723 | } | |
| 724 | ||
| 725 | private TextField createFindTextField() { | |
| 726 | return new TextField(); | |
| 727 | } | |
| 728 | ||
| 729 | /** | |
| 730 | * Create an editor pane to hold file editor tabs. | |
| 731 | * | |
| 732 | * @return A new instance, never null. | |
| 733 | */ | |
| 734 | private FileEditorTabPane createFileEditorPane() { | |
| 735 | return new FileEditorTabPane( mScrollEventObserver ); | |
| 736 | } | |
| 737 | ||
| 738 | private DefinitionFactory createDefinitionFactory() { | |
| 739 | return new DefinitionFactory(); | |
| 740 | } | |
| 741 | ||
| 742 | private StatusBar createStatusBar() { | |
| 743 | return new StatusBar(); | |
| 744 | } | |
| 745 | ||
| 746 | private Scene createScene() { | |
| 747 | final SplitPane splitPane = new SplitPane( | |
| 748 | getDefinitionPane().getNode(), | |
| 749 | getFileEditorPane().getNode(), | |
| 750 | getPreviewPane().getNode() ); | |
| 751 | ||
| 752 | splitPane.setDividerPositions( | |
| 753 | getFloat( K_PANE_SPLIT_DEFINITION, .10f ), | |
| 754 | getFloat( K_PANE_SPLIT_EDITOR, .45f ), | |
| 755 | getFloat( K_PANE_SPLIT_PREVIEW, .45f ) ); | |
| 756 | ||
| 757 | getDefinitionPane().prefHeightProperty().bind( splitPane.heightProperty() ); | |
| 758 | ||
| 759 | final BorderPane borderPane = new BorderPane(); | |
| 760 | borderPane.setPrefSize( 1024, 800 ); | |
| 761 | borderPane.setTop( createMenuBar() ); | |
| 762 | borderPane.setBottom( getStatusBar() ); | |
| 763 | borderPane.setCenter( splitPane ); | |
| 764 | ||
| 765 | final VBox statusBar = new VBox(); | |
| 766 | statusBar.setAlignment( Pos.BASELINE_CENTER ); | |
| 767 | statusBar.getChildren().add( getLineNumberText() ); | |
| 768 | getStatusBar().getRightItems().add( statusBar ); | |
| 769 | ||
| 770 | return new Scene( borderPane ); | |
| 771 | } | |
| 772 | ||
| 773 | private Text createLineNumberText() { | |
| 774 | return new Text( get( STATUS_BAR_LINE, 1, 1, 1 ) ); | |
| 775 | } | |
| 776 | ||
| 777 | private Node createMenuBar() { | |
| 778 | final BooleanBinding activeFileEditorIsNull = | |
| 779 | getFileEditorPane().activeFileEditorProperty().isNull(); | |
| 780 | ||
| 781 | // File actions | |
| 782 | final Action fileNewAction = new ActionBuilder() | |
| 783 | .setText( "Main.menu.file.new" ) | |
| 784 | .setAccelerator( "Shortcut+N" ) | |
| 785 | .setIcon( FILE_ALT ) | |
| 786 | .setAction( e -> fileNew() ) | |
| 787 | .build(); | |
| 788 | final Action fileOpenAction = new ActionBuilder() | |
| 789 | .setText( "Main.menu.file.open" ) | |
| 790 | .setAccelerator( "Shortcut+O" ) | |
| 791 | .setIcon( FOLDER_OPEN_ALT ) | |
| 792 | .setAction( e -> fileOpen() ) | |
| 793 | .build(); | |
| 794 | final Action fileCloseAction = new ActionBuilder() | |
| 795 | .setText( "Main.menu.file.close" ) | |
| 796 | .setAccelerator( "Shortcut+W" ) | |
| 797 | .setAction( e -> fileClose() ) | |
| 798 | .setDisable( activeFileEditorIsNull ) | |
| 799 | .build(); | |
| 800 | final Action fileCloseAllAction = new ActionBuilder() | |
| 801 | .setText( "Main.menu.file.close_all" ) | |
| 802 | .setAction( e -> fileCloseAll() ) | |
| 803 | .setDisable( activeFileEditorIsNull ) | |
| 804 | .build(); | |
| 805 | final Action fileSaveAction = new ActionBuilder() | |
| 806 | .setText( "Main.menu.file.save" ) | |
| 807 | .setAccelerator( "Shortcut+S" ) | |
| 808 | .setIcon( FLOPPY_ALT ) | |
| 809 | .setAction( e -> fileSave() ) | |
| 810 | .setDisable( createActiveBooleanProperty( | |
| 811 | FileEditorTab::modifiedProperty ).not() ) | |
| 812 | .build(); | |
| 813 | final Action fileSaveAsAction = new ActionBuilder() | |
| 814 | .setText( "Main.menu.file.save_as" ) | |
| 815 | .setAction( e -> fileSaveAs() ) | |
| 816 | .setDisable( activeFileEditorIsNull ) | |
| 817 | .build(); | |
| 818 | final Action fileSaveAllAction = new ActionBuilder() | |
| 819 | .setText( "Main.menu.file.save_all" ) | |
| 820 | .setAccelerator( "Shortcut+Shift+S" ) | |
| 821 | .setAction( e -> fileSaveAll() ) | |
| 822 | .setDisable( Bindings.not( | |
| 823 | getFileEditorPane().anyFileEditorModifiedProperty() ) ) | |
| 824 | .build(); | |
| 825 | final Action fileExitAction = new ActionBuilder() | |
| 826 | .setText( "Main.menu.file.exit" ) | |
| 827 | .setAction( e -> fileExit() ) | |
| 828 | .build(); | |
| 829 | ||
| 830 | // Edit actions | |
| 831 | final Action editUndoAction = new ActionBuilder() | |
| 832 | .setText( "Main.menu.edit.undo" ) | |
| 833 | .setAccelerator( "Shortcut+Z" ) | |
| 834 | .setIcon( UNDO ) | |
| 835 | .setAction( e -> getActiveEditor().undo() ) | |
| 836 | .setDisable( createActiveBooleanProperty( | |
| 837 | FileEditorTab::canUndoProperty ).not() ) | |
| 838 | .build(); | |
| 839 | final Action editRedoAction = new ActionBuilder() | |
| 840 | .setText( "Main.menu.edit.redo" ) | |
| 841 | .setAccelerator( "Shortcut+Y" ) | |
| 842 | .setIcon( REPEAT ) | |
| 843 | .setAction( e -> getActiveEditor().redo() ) | |
| 844 | .setDisable( createActiveBooleanProperty( | |
| 845 | FileEditorTab::canRedoProperty ).not() ) | |
| 846 | .build(); | |
| 847 | final Action editFindAction = new ActionBuilder() | |
| 848 | .setText( "Main.menu.edit.find" ) | |
| 849 | .setAccelerator( "Ctrl+F" ) | |
| 850 | .setIcon( SEARCH ) | |
| 851 | .setAction( e -> editFind() ) | |
| 852 | .setDisable( activeFileEditorIsNull ) | |
| 853 | .build(); | |
| 854 | final Action editFindNextAction = new ActionBuilder() | |
| 855 | .setText( "Main.menu.edit.find.next" ) | |
| 856 | .setAccelerator( "F3" ) | |
| 857 | .setIcon( null ) | |
| 858 | .setAction( e -> editFindNext() ) | |
| 859 | .setDisable( activeFileEditorIsNull ) | |
| 860 | .build(); | |
| 861 | final Action editPreferencesAction = new ActionBuilder() | |
| 862 | .setText( "Main.menu.edit.preferences" ) | |
| 863 | .setAccelerator( "Ctrl+Alt+S" ) | |
| 864 | .setAction( e -> editPreferences() ) | |
| 865 | .build(); | |
| 866 | ||
| 867 | // Insert actions | |
| 868 | final Action insertBoldAction = new ActionBuilder() | |
| 869 | .setText( "Main.menu.insert.bold" ) | |
| 870 | .setAccelerator( "Shortcut+B" ) | |
| 871 | .setIcon( BOLD ) | |
| 872 | .setAction( e -> insertMarkdown( "**", "**" ) ) | |
| 873 | .setDisable( activeFileEditorIsNull ) | |
| 874 | .build(); | |
| 875 | final Action insertItalicAction = new ActionBuilder() | |
| 876 | .setText( "Main.menu.insert.italic" ) | |
| 877 | .setAccelerator( "Shortcut+I" ) | |
| 878 | .setIcon( ITALIC ) | |
| 879 | .setAction( e -> insertMarkdown( "*", "*" ) ) | |
| 880 | .setDisable( activeFileEditorIsNull ) | |
| 881 | .build(); | |
| 882 | final Action insertSuperscriptAction = new ActionBuilder() | |
| 883 | .setText( "Main.menu.insert.superscript" ) | |
| 884 | .setAccelerator( "Shortcut+[" ) | |
| 885 | .setIcon( SUPERSCRIPT ) | |
| 886 | .setAction( e -> insertMarkdown( "^", "^" ) ) | |
| 887 | .setDisable( activeFileEditorIsNull ) | |
| 888 | .build(); | |
| 889 | final Action insertSubscriptAction = new ActionBuilder() | |
| 890 | .setText( "Main.menu.insert.subscript" ) | |
| 891 | .setAccelerator( "Shortcut+]" ) | |
| 892 | .setIcon( SUBSCRIPT ) | |
| 893 | .setAction( e -> insertMarkdown( "~", "~" ) ) | |
| 894 | .setDisable( activeFileEditorIsNull ) | |
| 895 | .build(); | |
| 896 | final Action insertStrikethroughAction = new ActionBuilder() | |
| 897 | .setText( "Main.menu.insert.strikethrough" ) | |
| 898 | .setAccelerator( "Shortcut+T" ) | |
| 899 | .setIcon( STRIKETHROUGH ) | |
| 900 | .setAction( e -> insertMarkdown( "~~", "~~" ) ) | |
| 901 | .setDisable( activeFileEditorIsNull ) | |
| 902 | .build(); | |
| 903 | final Action insertBlockquoteAction = new ActionBuilder() | |
| 904 | .setText( "Main.menu.insert.blockquote" ) | |
| 905 | .setAccelerator( "Ctrl+Q" ) | |
| 906 | .setIcon( QUOTE_LEFT ) | |
| 907 | .setAction( e -> insertMarkdown( "\n\n> ", "" ) ) | |
| 908 | .setDisable( activeFileEditorIsNull ) | |
| 909 | .build(); | |
| 910 | final Action insertCodeAction = new ActionBuilder() | |
| 911 | .setText( "Main.menu.insert.code" ) | |
| 912 | .setAccelerator( "Shortcut+K" ) | |
| 913 | .setIcon( CODE ) | |
| 914 | .setAction( e -> insertMarkdown( "`", "`" ) ) | |
| 915 | .setDisable( activeFileEditorIsNull ) | |
| 916 | .build(); | |
| 917 | final Action insertFencedCodeBlockAction = new ActionBuilder() | |
| 918 | .setText( "Main.menu.insert.fenced_code_block" ) | |
| 919 | .setAccelerator( "Shortcut+Shift+K" ) | |
| 920 | .setIcon( FILE_CODE_ALT ) | |
| 921 | .setAction( e -> getActiveEditor().surroundSelection( | |
| 922 | "\n\n```\n", | |
| 923 | "\n```\n\n", | |
| 924 | get( "Main.menu.insert.fenced_code_block.prompt" ) ) ) | |
| 925 | .setDisable( activeFileEditorIsNull ) | |
| 926 | .build(); | |
| 927 | final Action insertLinkAction = new ActionBuilder() | |
| 928 | .setText( "Main.menu.insert.link" ) | |
| 929 | .setAccelerator( "Shortcut+L" ) | |
| 930 | .setIcon( LINK ) | |
| 931 | .setAction( e -> getActiveEditor().insertLink() ) | |
| 932 | .setDisable( activeFileEditorIsNull ) | |
| 933 | .build(); | |
| 934 | final Action insertImageAction = new ActionBuilder() | |
| 935 | .setText( "Main.menu.insert.image" ) | |
| 936 | .setAccelerator( "Shortcut+G" ) | |
| 937 | .setIcon( PICTURE_ALT ) | |
| 938 | .setAction( e -> getActiveEditor().insertImage() ) | |
| 939 | .setDisable( activeFileEditorIsNull ) | |
| 940 | .build(); | |
| 941 | ||
| 942 | // Number of header actions (H1 ... H3) | |
| 943 | final int HEADERS = 3; | |
| 944 | final Action[] headers = new Action[ HEADERS ]; | |
| 945 | ||
| 946 | for( int i = 1; i <= HEADERS; i++ ) { | |
| 947 | final String hashes = new String( new char[ i ] ).replace( "\0", "#" ); | |
| 948 | final String markup = String.format( "%n%n%s ", hashes ); | |
| 949 | final String text = "Main.menu.insert.header." + i; | |
| 950 | final String accelerator = "Shortcut+" + i; | |
| 951 | final String prompt = text + ".prompt"; | |
| 952 | ||
| 953 | headers[ i - 1 ] = new ActionBuilder() | |
| 954 | .setText( text ) | |
| 955 | .setAccelerator( accelerator ) | |
| 956 | .setIcon( HEADER ) | |
| 957 | .setAction( e -> insertMarkdown( markup, "", get( prompt ) ) ) | |
| 958 | .setDisable( activeFileEditorIsNull ) | |
| 959 | .build(); | |
| 960 | } | |
| 961 | ||
| 962 | final Action insertUnorderedListAction = new ActionBuilder() | |
| 963 | .setText( "Main.menu.insert.unordered_list" ) | |
| 964 | .setAccelerator( "Shortcut+U" ) | |
| 965 | .setIcon( LIST_UL ) | |
| 966 | .setAction( e -> getActiveEditor() | |
| 967 | .surroundSelection( "\n\n* ", "" ) ) | |
| 968 | .setDisable( activeFileEditorIsNull ) | |
| 969 | .build(); | |
| 970 | final Action insertOrderedListAction = new ActionBuilder() | |
| 971 | .setText( "Main.menu.insert.ordered_list" ) | |
| 972 | .setAccelerator( "Shortcut+Shift+O" ) | |
| 973 | .setIcon( LIST_OL ) | |
| 974 | .setAction( e -> insertMarkdown( | |
| 975 | "\n\n1. ", "" ) ) | |
| 976 | .setDisable( activeFileEditorIsNull ) | |
| 977 | .build(); | |
| 978 | final Action insertHorizontalRuleAction = new ActionBuilder() | |
| 979 | .setText( "Main.menu.insert.horizontal_rule" ) | |
| 980 | .setAccelerator( "Shortcut+H" ) | |
| 981 | .setAction( e -> insertMarkdown( | |
| 982 | "\n\n---\n\n", "" ) ) | |
| 983 | .setDisable( activeFileEditorIsNull ) | |
| 984 | .build(); | |
| 985 | ||
| 986 | // Help actions | |
| 987 | final Action helpAboutAction = new ActionBuilder() | |
| 988 | .setText( "Main.menu.help.about" ) | |
| 989 | .setAction( e -> helpAbout() ) | |
| 990 | .build(); | |
| 991 | ||
| 992 | //---- MenuBar ---- | |
| 993 | final Menu fileMenu = ActionUtils.createMenu( | |
| 994 | get( "Main.menu.file" ), | |
| 995 | fileNewAction, | |
| 996 | fileOpenAction, | |
| 997 | null, | |
| 998 | fileCloseAction, | |
| 999 | fileCloseAllAction, | |
| 1000 | null, | |
| 1001 | fileSaveAction, | |
| 1002 | fileSaveAsAction, | |
| 1003 | fileSaveAllAction, | |
| 1004 | null, | |
| 1005 | fileExitAction ); | |
| 1006 | ||
| 1007 | final Menu editMenu = ActionUtils.createMenu( | |
| 1008 | get( "Main.menu.edit" ), | |
| 1009 | editUndoAction, | |
| 1010 | editRedoAction, | |
| 1011 | editFindAction, | |
| 1012 | editFindNextAction, | |
| 1013 | null, | |
| 1014 | editPreferencesAction ); | |
| 1015 | ||
| 1016 | final Menu insertMenu = ActionUtils.createMenu( | |
| 1017 | get( "Main.menu.insert" ), | |
| 1018 | insertBoldAction, | |
| 1019 | insertItalicAction, | |
| 1020 | insertSuperscriptAction, | |
| 1021 | insertSubscriptAction, | |
| 1022 | insertStrikethroughAction, | |
| 1023 | insertBlockquoteAction, | |
| 1024 | insertCodeAction, | |
| 1025 | insertFencedCodeBlockAction, | |
| 1026 | null, | |
| 1027 | insertLinkAction, | |
| 1028 | insertImageAction, | |
| 1029 | null, | |
| 1030 | headers[ 0 ], | |
| 1031 | headers[ 1 ], | |
| 1032 | headers[ 2 ], | |
| 1033 | null, | |
| 1034 | insertUnorderedListAction, | |
| 1035 | insertOrderedListAction, | |
| 1036 | insertHorizontalRuleAction ); | |
| 1037 | ||
| 1038 | final Menu helpMenu = ActionUtils.createMenu( | |
| 1039 | get( "Main.menu.help" ), | |
| 1040 | helpAboutAction ); | |
| 1041 | ||
| 1042 | final MenuBar menuBar = new MenuBar( | |
| 1043 | fileMenu, | |
| 1044 | editMenu, | |
| 1045 | insertMenu, | |
| 1046 | helpMenu ); | |
| 1047 | ||
| 1048 | //---- ToolBar ---- | |
| 1049 | final ToolBar toolBar = ActionUtils.createToolBar( | |
| 1050 | fileNewAction, | |
| 1051 | fileOpenAction, | |
| 1052 | fileSaveAction, | |
| 1053 | null, | |
| 1054 | editUndoAction, | |
| 1055 | editRedoAction, | |
| 1056 | null, | |
| 1057 | insertBoldAction, | |
| 1058 | insertItalicAction, | |
| 1059 | insertSuperscriptAction, | |
| 1060 | insertSubscriptAction, | |
| 1061 | insertBlockquoteAction, | |
| 1062 | insertCodeAction, | |
| 1063 | insertFencedCodeBlockAction, | |
| 1064 | null, | |
| 1065 | insertLinkAction, | |
| 1066 | insertImageAction, | |
| 1067 | null, | |
| 1068 | headers[ 0 ], | |
| 1069 | null, | |
| 1070 | insertUnorderedListAction, | |
| 1071 | insertOrderedListAction ); | |
| 1072 | ||
| 1073 | return new VBox( menuBar, toolBar ); | |
| 1074 | } | |
| 1075 | ||
| 1076 | private UserPreferences createUserPreferences() { | |
| 1077 | return new UserPreferences(); | |
| 1078 | } | |
| 1079 | ||
| 1080 | /** | |
| 1081 | * Creates a boolean property that is bound to another boolean value of the | |
| 1082 | * active editor. | |
| 1083 | */ | |
| 1084 | private BooleanProperty createActiveBooleanProperty( | |
| 1085 | final Function<FileEditorTab, ObservableBooleanValue> func ) { | |
| 1086 | ||
| 1087 | final BooleanProperty b = new SimpleBooleanProperty(); | |
| 1088 | final FileEditorTab tab = getActiveFileEditor(); | |
| 1089 | ||
| 1090 | if( tab != null ) { | |
| 1091 | b.bind( func.apply( tab ) ); | |
| 1092 | } | |
| 1093 | ||
| 1094 | getFileEditorPane().activeFileEditorProperty().addListener( | |
| 1095 | ( observable, oldFileEditor, newFileEditor ) -> { | |
| 1096 | b.unbind(); | |
| 1097 | ||
| 1098 | if( newFileEditor == null ) { | |
| 1099 | b.set( false ); | |
| 1100 | } | |
| 1101 | else { | |
| 1102 | b.bind( func.apply( newFileEditor ) ); | |
| 1103 | } | |
| 1104 | } | |
| 1105 | ); | |
| 1106 | ||
| 1107 | return b; | |
| 1108 | } | |
| 1109 | ||
| 1110 | //---- Convenience accessors ---------------------------------------------- | |
| 1111 | ||
| 1112 | private Preferences getPreferences() { | |
| 1113 | return OPTIONS.getState(); | |
| 1114 | } | |
| 1115 | ||
| 1116 | private float getFloat( final String key, final float defaultValue ) { | |
| 1117 | return getPreferences().getFloat( key, defaultValue ); | |
| 1118 | } | |
| 1119 | ||
| 1120 | public Window getWindow() { | |
| 1121 | return getScene().getWindow(); | |
| 1122 | } | |
| 1123 | ||
| 1124 | private MarkdownEditorPane getActiveEditor() { | |
| 1125 | final EditorPane pane = getActiveFileEditor().getEditorPane(); | |
| 1126 | ||
| 1127 | return pane instanceof MarkdownEditorPane | |
| 1128 | ? (MarkdownEditorPane) pane | |
| 1129 | : new MarkdownEditorPane(); | |
| 1130 | } | |
| 1131 | ||
| 1132 | private FileEditorTab getActiveFileEditor() { | |
| 1133 | return getFileEditorPane().getActiveFileEditor(); | |
| 1134 | } | |
| 1135 | ||
| 1136 | //---- Member accessors --------------------------------------------------- | |
| 1137 | ||
| 1138 | protected Scene getScene() { | |
| 1139 | return mScene; | |
| 1140 | } | |
| 1141 | ||
| 1142 | private Map<FileEditorTab, Processor<String>> getProcessors() { | |
| 1143 | return mProcessors; | |
| 1144 | } | |
| 1145 | ||
| 1146 | private FileEditorTabPane getFileEditorPane() { | |
| 1147 | if( this.fileEditorPane == null ) { | |
| 1148 | this.fileEditorPane = createFileEditorPane(); | |
| 1149 | } | |
| 1150 | ||
| 1151 | return this.fileEditorPane; | |
| 1152 | } | |
| 1153 | ||
| 1154 | private HTMLPreviewPane getPreviewPane() { | |
| 1155 | return mPreviewPane; | |
| 1156 | } | |
| 1157 | ||
| 1158 | private void setDefinitionSource( final DefinitionSource definitionSource ) { | |
| 1159 | assert definitionSource != null; | |
| 1160 | mDefinitionSource = definitionSource; | |
| 1161 | } | |
| 1162 | ||
| 1163 | private DefinitionSource getDefinitionSource() { | |
| 1164 | return mDefinitionSource; | |
| 1165 | } | |
| 1166 | ||
| 1167 | private DefinitionPane getDefinitionPane() { | |
| 1168 | return mDefinitionPane; | |
| 1169 | } | |
| 1170 | ||
| 1171 | private Notifier getNotifier() { | |
| 1172 | return NOTIFIER; | |
| 1173 | } | |
| 1174 | ||
| 1175 | private Text getLineNumberText() { | |
| 1176 | return mLineNumberText; | |
| 1177 | } | |
| 1178 | ||
| 1179 | private StatusBar getStatusBar() { | |
| 1180 | return mStatusBar; | |
| 1181 | } | |
| 1182 | ||
| 1183 | private TextField getFindTextField() { | |
| 1184 | return mFindTextField; | |
| 1185 | } | |
| 1186 | ||
| 1187 | /** | |
| 1188 | * Returns the variable map of interpolated definitions. | |
| 1189 | * | |
| 1190 | * @return A map to help dereference variables. | |
| 1191 | */ | |
| 1192 | private Map<String, String> getResolvedMap() { | |
| 1193 | return mResolvedMap; | |
| 1194 | } | |
| 1195 | ||
| 1196 | //---- Persistence accessors ---------------------------------------------- | |
| 1197 | private UserPreferences getUserPreferences() { | |
| 1198 | return OPTIONS.getUserPreferences(); | |
| 1199 | } | |
| 1200 | ||
| 1201 | private Path getDefinitionPath() { | |
| 1202 | return getUserPreferences().getDefinitionPath(); | |
| 1203 | } | |
| 1204 | ||
| 1205 | private File getImagesDirectory() { | |
| 1206 | return getUserPreferences().getImagesDirectory(); | |
| 1207 | } | |
| 1208 | ||
| 1209 | private String getImagesOrder() { | |
| 1210 | return getUserPreferences().getImagesOrder(); | |
| 1142 | 1211 | } |
| 1143 | 1212 | } |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | public static String get( final String key, final boolean interpolate ) { | |
| 125 | return interpolate ? get( key ) : getLiteral( key ); | |
| 126 | } | |
| 127 | ||
| 124 | 128 | /** |
| 125 | 129 | * Returns the value for a key from the message bundle with the arguments |
| 31 | 31 | import com.scrivenvar.FileType; |
| 32 | 32 | import com.scrivenvar.definition.yaml.YamlDefinitionSource; |
| 33 | import com.scrivenvar.util.ProtocolResolver; | |
| 33 | 34 | |
| 34 | import java.io.File; | |
| 35 | import java.net.URI; | |
| 36 | import java.net.URL; | |
| 37 | 35 | import java.nio.file.Path; |
| 38 | 36 | |
| 39 | import static com.scrivenvar.Constants.*; | |
| 37 | import static com.scrivenvar.Constants.DEFINITION_PROTOCOL_FILE; | |
| 38 | import static com.scrivenvar.Constants.GLOB_PREFIX_DEFINITION; | |
| 40 | 39 | import static com.scrivenvar.FileType.YAML; |
| 41 | 40 | |
| ... | ||
| 65 | 64 | assert path != null; |
| 66 | 65 | |
| 67 | final String protocol = getProtocol( path.toString() ); | |
| 66 | final String protocol = ProtocolResolver.getProtocol( path.toString() ); | |
| 68 | 67 | DefinitionSource result = null; |
| 69 | 68 | |
| ... | ||
| 96 | 95 | |
| 97 | 96 | throw new IllegalArgumentException( filetype.toString() ); |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Returns the protocol for a given URI or filename. | |
| 102 | * | |
| 103 | * @param source Determine the protocol for this URI or filename. | |
| 104 | * @return The protocol for the given source. | |
| 105 | */ | |
| 106 | private String getProtocol( final String source ) { | |
| 107 | String protocol; | |
| 108 | ||
| 109 | try { | |
| 110 | final URI uri = new URI( source ); | |
| 111 | ||
| 112 | if( uri.isAbsolute() ) { | |
| 113 | protocol = uri.getScheme(); | |
| 114 | } | |
| 115 | else { | |
| 116 | final URL url = new URL( source ); | |
| 117 | protocol = url.getProtocol(); | |
| 118 | } | |
| 119 | } catch( final Exception e ) { | |
| 120 | // Could be HTTP, HTTPS? | |
| 121 | if( source.startsWith( "//" ) ) { | |
| 122 | throw new IllegalArgumentException( "Relative context: " + source ); | |
| 123 | } | |
| 124 | else { | |
| 125 | final File file = new File( source ); | |
| 126 | protocol = getProtocol( file ); | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | return protocol; | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Returns the protocol for a given file. | |
| 135 | * | |
| 136 | * @param file Determine the protocol for this file. | |
| 137 | * @return The protocol for the given file. | |
| 138 | */ | |
| 139 | private String getProtocol( final File file ) { | |
| 140 | String result; | |
| 141 | ||
| 142 | try { | |
| 143 | result = file.toURI().toURL().getProtocol(); | |
| 144 | } catch( final Exception e ) { | |
| 145 | result = DEFINITION_PROTOCOL_UNKNOWN; | |
| 146 | } | |
| 147 | ||
| 148 | return result; | |
| 149 | 97 | } |
| 150 | 98 | } |
| 45 | 45 | |
| 46 | 46 | /** |
| 47 | * Provides the user interface that holdsa {@link TreeView}, which | |
| 48 | * allows users to interact with key/value pairs loaded from the | |
| 49 | * {@link DocumentParser} and adapted using a {@link TreeAdapter}. | |
| 50 | * | |
| 51 | * @author White Magic Software, Ltd. | |
| 52 | */ | |
| 53 | public final class DefinitionPane extends TitledPane { | |
| 54 | ||
| 55 | /** | |
| 56 | * Trimmed off the end of a word to match a variable name. | |
| 57 | */ | |
| 58 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; | |
| 59 | ||
| 60 | /** | |
| 61 | * Contains a view of the definitions. | |
| 62 | */ | |
| 63 | private final TreeView<String> mTreeView = new TreeView<>(); | |
| 64 | ||
| 65 | /** | |
| 66 | * Handlers for key press events. | |
| 67 | */ | |
| 68 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 69 | = new HashSet<>(); | |
| 70 | ||
| 71 | /** | |
| 72 | * Definition file name shown in the title of the pane. | |
| 73 | */ | |
| 74 | private final StringProperty mFilename = new SimpleStringProperty(); | |
| 75 | ||
| 76 | /** | |
| 77 | * Constructs a definition pane with a given tree view root. | |
| 78 | */ | |
| 79 | public DefinitionPane() { | |
| 80 | final var treeView = getTreeView(); | |
| 81 | treeView.setEditable( true ); | |
| 82 | treeView.setCellFactory( cell -> createTreeCell() ); | |
| 83 | treeView.setContextMenu( createContextMenu() ); | |
| 84 | treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 85 | treeView.setShowRoot( false ); | |
| 86 | getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE ); | |
| 87 | ||
| 88 | textProperty().bind( mFilename ); | |
| 89 | ||
| 90 | setContent( treeView ); | |
| 91 | setCollapsible( false ); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Changes the root of the {@link TreeView} to the root of the | |
| 96 | * {@link TreeView} from the {@link DefinitionSource}. | |
| 97 | * | |
| 98 | * @param definitionSource Container for the hierarchy of key/value pairs | |
| 99 | * to replace the existing hierarchy. | |
| 100 | */ | |
| 101 | public void update( final DefinitionSource definitionSource ) { | |
| 102 | assert definitionSource != null; | |
| 103 | ||
| 104 | final TreeAdapter treeAdapter = definitionSource.getTreeAdapter(); | |
| 105 | final TreeItem<String> root = treeAdapter.adapt( | |
| 106 | get( "Pane.definition.node.root.title" ) | |
| 107 | ); | |
| 108 | ||
| 109 | getTreeView().setRoot( root ); | |
| 110 | } | |
| 111 | ||
| 112 | public Map<String, String> toMap() { | |
| 113 | return TreeItemAdapter.toMap( getTreeView().getRoot() ); | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 118 | * is modified. The modifications include: item value changes, item additions, | |
| 119 | * and item removals. | |
| 120 | * <p> | |
| 121 | * Safe to call multiple times; if a handler is already registered, the | |
| 122 | * old handler is used. | |
| 123 | * </p> | |
| 124 | * | |
| 125 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 126 | */ | |
| 127 | public void addTreeChangeHandler( | |
| 128 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 129 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 130 | root.addEventHandler( TreeItem.valueChangedEvent(), handler ); | |
| 131 | root.addEventHandler( TreeItem.childrenModificationEvent(), handler ); | |
| 132 | } | |
| 133 | ||
| 134 | public void addKeyEventHandler( | |
| 135 | final EventHandler<? super KeyEvent> handler ) { | |
| 136 | getKeyEventHandlers().add( handler ); | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 141 | * well-formed for export. A tree is considered well-formed if the following | |
| 142 | * conditions are met: | |
| 143 | * | |
| 144 | * <ul> | |
| 145 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 146 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 147 | * </ul> | |
| 148 | * | |
| 149 | * @return {@code null} if the document is well-formed, otherwise the | |
| 150 | * problematic child {@link TreeItem}. | |
| 151 | */ | |
| 152 | public TreeItem<String> isTreeWellFormed() { | |
| 153 | final var root = getTreeView().getRoot(); | |
| 154 | ||
| 155 | for( final var child : root.getChildren() ) { | |
| 156 | final var problemChild = isWellFormed( child ); | |
| 157 | ||
| 158 | if( child.isLeaf() || problemChild != null ) { | |
| 159 | return problemChild; | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | return null; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Determines whether the document is well-formed by ensuring that | |
| 168 | * child branches do not contain multiple leaves. | |
| 169 | * | |
| 170 | * @param item The sub-tree to check for well-formedness. | |
| 171 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 172 | * problematic {@link TreeItem}. | |
| 173 | */ | |
| 174 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 175 | int childLeafs = 0; | |
| 176 | int childBranches = 0; | |
| 177 | ||
| 178 | for( final TreeItem<String> child : item.getChildren() ) { | |
| 179 | if( child.isLeaf() ) { | |
| 180 | childLeafs++; | |
| 181 | } | |
| 182 | else { | |
| 183 | childBranches++; | |
| 184 | } | |
| 185 | ||
| 186 | final var problemChild = isWellFormed( child ); | |
| 187 | ||
| 188 | if( problemChild != null ) { | |
| 189 | return problemChild; | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | return ((childBranches > 0 && childLeafs == 0) || | |
| 194 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Returns the leaf that matches the given value. If the value is terminally | |
| 199 | * punctuated, the punctuation is removed if no match was found. | |
| 200 | * | |
| 201 | * @param value The value to find, never null. | |
| 202 | * @param findMode Defines how to match words. | |
| 203 | * @return The leaf that contains the given value, or null if neither the | |
| 204 | * original value nor the terminally-trimmed value was found. | |
| 205 | */ | |
| 206 | public VariableTreeItem<String> findLeaf( | |
| 207 | final String value, final FindMode findMode ) { | |
| 208 | final VariableTreeItem<String> root = getTreeRoot(); | |
| 209 | final VariableTreeItem<String> leaf = root.findLeaf( value, findMode ); | |
| 210 | ||
| 211 | return leaf == null | |
| 212 | ? root.findLeaf( rtrimTerminalPunctuation( value ) ) | |
| 213 | : leaf; | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Removes punctuation from the end of a string. | |
| 218 | * | |
| 219 | * @param s The string to trim, never null. | |
| 220 | * @return The string trimmed of all terminal characters from the end | |
| 221 | */ | |
| 222 | private String rtrimTerminalPunctuation( final String s ) { | |
| 223 | assert s != null; | |
| 224 | int index = s.length() - 1; | |
| 225 | ||
| 226 | while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) { | |
| 227 | index--; | |
| 228 | } | |
| 229 | ||
| 230 | return s.substring( 0, index ); | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * Expands the node to the root, recursively. | |
| 235 | * | |
| 236 | * @param <T> The type of tree item to expand (usually String). | |
| 237 | * @param node The node to expand. | |
| 238 | */ | |
| 239 | public <T> void expand( final TreeItem<T> node ) { | |
| 240 | if( node != null ) { | |
| 241 | expand( node.getParent() ); | |
| 242 | ||
| 243 | if( !node.isLeaf() ) { | |
| 244 | node.setExpanded( true ); | |
| 245 | } | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | public void select( final TreeItem<String> item ) { | |
| 250 | getSelectionModel().clearSelection(); | |
| 251 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * Collapses the tree, recursively. | |
| 256 | */ | |
| 257 | public void collapse() { | |
| 258 | collapse( getTreeRoot().getChildren() ); | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Collapses the tree, recursively. | |
| 263 | * | |
| 264 | * @param <T> The type of tree item to expand (usually String). | |
| 265 | * @param nodes The nodes to collapse. | |
| 266 | */ | |
| 267 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 268 | for( final TreeItem<T> node : nodes ) { | |
| 269 | node.setExpanded( false ); | |
| 270 | collapse( node.getChildren() ); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 276 | */ | |
| 277 | private boolean isEditingTreeItem() { | |
| 278 | return getTreeView().editingItemProperty().getValue() != null; | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Changes to edit mode for the selected item. | |
| 283 | */ | |
| 284 | private void editSelectedItem() { | |
| 285 | getTreeView().edit( getSelectedItem() ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Removes all selected items from the {@link TreeView}. | |
| 290 | */ | |
| 291 | private void deleteSelectedItems() { | |
| 292 | for( final TreeItem<String> item : getSelectedItems() ) { | |
| 293 | final TreeItem<String> parent = item.getParent(); | |
| 294 | ||
| 295 | if( parent != null ) { | |
| 296 | parent.getChildren().remove( item ); | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Deletes the selected item. | |
| 303 | */ | |
| 304 | private void deleteSelectedItem() { | |
| 305 | final TreeItem<String> c = getSelectedItem(); | |
| 306 | getSiblings( c ).remove( c ); | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 311 | * There are a few conditions to consider: when adding to the root, | |
| 312 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 313 | * root must contain two items: a key and a value. | |
| 314 | */ | |
| 315 | private void addItem() { | |
| 316 | final TreeItem<String> value = createTreeItem(); | |
| 317 | getSelectedItem().getChildren().add( value ); | |
| 318 | expand( value ); | |
| 319 | select( value ); | |
| 320 | } | |
| 321 | ||
| 322 | private ContextMenu createContextMenu() { | |
| 323 | final ContextMenu menu = new ContextMenu(); | |
| 324 | final ObservableList<MenuItem> items = menu.getItems(); | |
| 325 | ||
| 326 | addMenuItem( items, "Definition.menu.create" ) | |
| 327 | .setOnAction( e -> addItem() ); | |
| 328 | ||
| 329 | addMenuItem( items, "Definition.menu.rename" ) | |
| 330 | .setOnAction( e -> editSelectedItem() ); | |
| 331 | ||
| 332 | addMenuItem( items, "Definition.menu.remove" ) | |
| 333 | .setOnAction( e -> deleteSelectedItem() ); | |
| 334 | ||
| 335 | return menu; | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Executes hot-keys for edits to the definition tree. | |
| 340 | * | |
| 341 | * @param event Contains the key code of the key that was pressed. | |
| 342 | */ | |
| 343 | private void keyEventFilter( final KeyEvent event ) { | |
| 344 | if( !isEditingTreeItem() ) { | |
| 345 | switch( event.getCode() ) { | |
| 346 | case ENTER: | |
| 347 | expand( getSelectedItem() ); | |
| 348 | event.consume(); | |
| 349 | break; | |
| 350 | ||
| 351 | case DELETE: | |
| 352 | deleteSelectedItems(); | |
| 353 | break; | |
| 354 | ||
| 355 | case INSERT: | |
| 356 | addItem(); | |
| 357 | break; | |
| 358 | ||
| 359 | case R: | |
| 360 | if( event.isControlDown() ) { | |
| 361 | editSelectedItem(); | |
| 362 | } | |
| 363 | ||
| 364 | break; | |
| 365 | } | |
| 366 | ||
| 367 | for( final var handler : getKeyEventHandlers() ) { | |
| 368 | handler.handle( event ); | |
| 369 | } | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Adds a menu item to a list of menu items. | |
| 375 | * | |
| 376 | * @param items The list of menu items to append to. | |
| 377 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 378 | * @return The menu item added to the list of menu items. | |
| 379 | */ | |
| 380 | private MenuItem addMenuItem( | |
| 381 | final List<MenuItem> items, final String labelKey ) { | |
| 382 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 383 | items.add( menuItem ); | |
| 384 | return menuItem; | |
| 385 | } | |
| 386 | ||
| 387 | private MenuItem createMenuItem( final String labelKey ) { | |
| 388 | return new MenuItem( get( labelKey ) ); | |
| 389 | } | |
| 390 | ||
| 391 | private VariableTreeItem<String> createTreeItem() { | |
| 392 | return new VariableTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 393 | } | |
| 394 | ||
| 395 | private TreeCell<String> createTreeCell() { | |
| 396 | return new TextFieldTreeCell<>( | |
| 397 | createStringConverter() ) { | |
| 398 | @Override | |
| 399 | public void commitEdit( final String newValue ) { | |
| 400 | super.commitEdit( newValue ); | |
| 401 | select( getTreeItem() ); | |
| 402 | requestFocus(); | |
| 403 | } | |
| 404 | }; | |
| 405 | } | |
| 406 | ||
| 407 | @Override | |
| 408 | public void requestFocus() { | |
| 409 | super.requestFocus(); | |
| 410 | getTreeView().requestFocus(); | |
| 411 | } | |
| 412 | ||
| 413 | private StringConverter<String> createStringConverter() { | |
| 414 | return new StringConverter<>() { | |
| 415 | @Override | |
| 416 | public String toString( final String object ) { | |
| 417 | return object == null ? "" : object; | |
| 418 | } | |
| 419 | ||
| 420 | @Override | |
| 421 | public String fromString( final String string ) { | |
| 422 | return string == null ? "" : string; | |
| 423 | } | |
| 424 | }; | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Returns the tree view that contains the definition hierarchy. | |
| 429 | * | |
| 430 | * @return A non-null instance. | |
| 431 | */ | |
| 432 | public TreeView<String> getTreeView() { | |
| 433 | return mTreeView; | |
| 434 | } | |
| 435 | ||
| 436 | /** | |
| 437 | * Returns this pane. | |
| 438 | * | |
| 439 | * @return this | |
| 440 | */ | |
| 441 | public Node getNode() { | |
| 442 | return this; | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Returns the property used to set the title of the pane: the file name. | |
| 447 | * | |
| 448 | * @return A non-null property used for showing the definition file name. | |
| 449 | */ | |
| 450 | public StringProperty filenameProperty() { | |
| 451 | return mFilename; | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Returns the root of the tree. | |
| 456 | * | |
| 457 | * @return The first node added to the definition tree. | |
| 458 | */ | |
| 459 | private VariableTreeItem<String> getTreeRoot() { | |
| 460 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 461 | ||
| 462 | return root instanceof VariableTreeItem ? | |
| 463 | (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" ); | |
| 464 | } | |
| 465 | ||
| 466 | private ObservableList<TreeItem<String>> getSiblings( | |
| 467 | final TreeItem<String> item ) { | |
| 468 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 469 | final TreeItem<String> parent = | |
| 470 | (item == null || item == root) ? root : item.getParent(); | |
| 471 | ||
| 472 | return parent.getChildren(); | |
| 473 | } | |
| 474 | ||
| 475 | private MultipleSelectionModel<TreeItem<String>> getSelectionModel() { | |
| 476 | return getTreeView().getSelectionModel(); | |
| 477 | } | |
| 478 | ||
| 479 | /** | |
| 480 | * Returns a copy of all the selected items. | |
| 481 | * | |
| 482 | * @return A list, possibly empty, containing all selected items in the | |
| 483 | * {@link TreeView}. | |
| 484 | */ | |
| 485 | private List<TreeItem<String>> getSelectedItems() { | |
| 486 | return new LinkedList<>( getSelectionModel().getSelectedItems() ); | |
| 47 | * Provides the user interface that holds a {@link TreeView}, which | |
| 48 | * allows users to interact with key/value pairs loaded from the | |
| 49 | * {@link DocumentParser} and adapted using a {@link TreeAdapter}. | |
| 50 | * | |
| 51 | * @author White Magic Software, Ltd. | |
| 52 | */ | |
| 53 | public final class DefinitionPane extends TitledPane { | |
| 54 | ||
| 55 | /** | |
| 56 | * Trimmed off the end of a word to match a variable name. | |
| 57 | */ | |
| 58 | private final static String TERMINALS = ":;,.!?-/\\¡¿"; | |
| 59 | ||
| 60 | /** | |
| 61 | * Contains a view of the definitions. | |
| 62 | */ | |
| 63 | private final TreeView<String> mTreeView = new TreeView<>(); | |
| 64 | ||
| 65 | /** | |
| 66 | * Handlers for key press events. | |
| 67 | */ | |
| 68 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 69 | = new HashSet<>(); | |
| 70 | ||
| 71 | /** | |
| 72 | * Definition file name shown in the title of the pane. | |
| 73 | */ | |
| 74 | private final StringProperty mFilename = new SimpleStringProperty(); | |
| 75 | ||
| 76 | /** | |
| 77 | * Constructs a definition pane with a given tree view root. | |
| 78 | */ | |
| 79 | public DefinitionPane() { | |
| 80 | final var treeView = getTreeView(); | |
| 81 | treeView.setEditable( true ); | |
| 82 | treeView.setCellFactory( cell -> createTreeCell() ); | |
| 83 | treeView.setContextMenu( createContextMenu() ); | |
| 84 | treeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 85 | treeView.setShowRoot( false ); | |
| 86 | getSelectionModel().setSelectionMode( SelectionMode.MULTIPLE ); | |
| 87 | ||
| 88 | textProperty().bind( mFilename ); | |
| 89 | ||
| 90 | setContent( treeView ); | |
| 91 | setCollapsible( false ); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Changes the root of the {@link TreeView} to the root of the | |
| 96 | * {@link TreeView} from the {@link DefinitionSource}. | |
| 97 | * | |
| 98 | * @param definitionSource Container for the hierarchy of key/value pairs | |
| 99 | * to replace the existing hierarchy. | |
| 100 | */ | |
| 101 | public void update( final DefinitionSource definitionSource ) { | |
| 102 | assert definitionSource != null; | |
| 103 | ||
| 104 | final TreeAdapter treeAdapter = definitionSource.getTreeAdapter(); | |
| 105 | final TreeItem<String> root = treeAdapter.adapt( | |
| 106 | get( "Pane.definition.node.root.title" ) | |
| 107 | ); | |
| 108 | ||
| 109 | getTreeView().setRoot( root ); | |
| 110 | } | |
| 111 | ||
| 112 | public Map<String, String> toMap() { | |
| 113 | return TreeItemAdapter.toMap( getTreeView().getRoot() ); | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 118 | * is modified. The modifications include: item value changes, item additions, | |
| 119 | * and item removals. | |
| 120 | * <p> | |
| 121 | * Safe to call multiple times; if a handler is already registered, the | |
| 122 | * old handler is used. | |
| 123 | * </p> | |
| 124 | * | |
| 125 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 126 | */ | |
| 127 | public void addTreeChangeHandler( | |
| 128 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 129 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 130 | root.addEventHandler( TreeItem.valueChangedEvent(), handler ); | |
| 131 | root.addEventHandler( TreeItem.childrenModificationEvent(), handler ); | |
| 132 | } | |
| 133 | ||
| 134 | public void addKeyEventHandler( | |
| 135 | final EventHandler<? super KeyEvent> handler ) { | |
| 136 | getKeyEventHandlers().add( handler ); | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 141 | * well-formed for export. A tree is considered well-formed if the following | |
| 142 | * conditions are met: | |
| 143 | * | |
| 144 | * <ul> | |
| 145 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 146 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 147 | * </ul> | |
| 148 | * | |
| 149 | * @return {@code null} if the document is well-formed, otherwise the | |
| 150 | * problematic child {@link TreeItem}. | |
| 151 | */ | |
| 152 | public TreeItem<String> isTreeWellFormed() { | |
| 153 | final var root = getTreeView().getRoot(); | |
| 154 | ||
| 155 | for( final var child : root.getChildren() ) { | |
| 156 | final var problemChild = isWellFormed( child ); | |
| 157 | ||
| 158 | if( child.isLeaf() || problemChild != null ) { | |
| 159 | return problemChild; | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | return null; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Determines whether the document is well-formed by ensuring that | |
| 168 | * child branches do not contain multiple leaves. | |
| 169 | * | |
| 170 | * @param item The sub-tree to check for well-formedness. | |
| 171 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 172 | * problematic {@link TreeItem}. | |
| 173 | */ | |
| 174 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 175 | int childLeafs = 0; | |
| 176 | int childBranches = 0; | |
| 177 | ||
| 178 | for( final TreeItem<String> child : item.getChildren() ) { | |
| 179 | if( child.isLeaf() ) { | |
| 180 | childLeafs++; | |
| 181 | } | |
| 182 | else { | |
| 183 | childBranches++; | |
| 184 | } | |
| 185 | ||
| 186 | final var problemChild = isWellFormed( child ); | |
| 187 | ||
| 188 | if( problemChild != null ) { | |
| 189 | return problemChild; | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | return ((childBranches > 0 && childLeafs == 0) || | |
| 194 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Returns the leaf that matches the given value. If the value is terminally | |
| 199 | * punctuated, the punctuation is removed if no match was found. | |
| 200 | * | |
| 201 | * @param value The value to find, never null. | |
| 202 | * @param findMode Defines how to match words. | |
| 203 | * @return The leaf that contains the given value, or null if neither the | |
| 204 | * original value nor the terminally-trimmed value was found. | |
| 205 | */ | |
| 206 | public VariableTreeItem<String> findLeaf( | |
| 207 | final String value, final FindMode findMode ) { | |
| 208 | final VariableTreeItem<String> root = getTreeRoot(); | |
| 209 | final VariableTreeItem<String> leaf = root.findLeaf( value, findMode ); | |
| 210 | ||
| 211 | return leaf == null | |
| 212 | ? root.findLeaf( rtrimTerminalPunctuation( value ) ) | |
| 213 | : leaf; | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Removes punctuation from the end of a string. | |
| 218 | * | |
| 219 | * @param s The string to trim, never null. | |
| 220 | * @return The string trimmed of all terminal characters from the end | |
| 221 | */ | |
| 222 | private String rtrimTerminalPunctuation( final String s ) { | |
| 223 | assert s != null; | |
| 224 | int index = s.length() - 1; | |
| 225 | ||
| 226 | while( index > 0 && (TERMINALS.indexOf( s.charAt( index ) ) >= 0) ) { | |
| 227 | index--; | |
| 228 | } | |
| 229 | ||
| 230 | return s.substring( 0, index ); | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * Expands the node to the root, recursively. | |
| 235 | * | |
| 236 | * @param <T> The type of tree item to expand (usually String). | |
| 237 | * @param node The node to expand. | |
| 238 | */ | |
| 239 | public <T> void expand( final TreeItem<T> node ) { | |
| 240 | if( node != null ) { | |
| 241 | expand( node.getParent() ); | |
| 242 | ||
| 243 | if( !node.isLeaf() ) { | |
| 244 | node.setExpanded( true ); | |
| 245 | } | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | public void select( final TreeItem<String> item ) { | |
| 250 | getSelectionModel().clearSelection(); | |
| 251 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * Collapses the tree, recursively. | |
| 256 | */ | |
| 257 | public void collapse() { | |
| 258 | collapse( getTreeRoot().getChildren() ); | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Collapses the tree, recursively. | |
| 263 | * | |
| 264 | * @param <T> The type of tree item to expand (usually String). | |
| 265 | * @param nodes The nodes to collapse. | |
| 266 | */ | |
| 267 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 268 | for( final TreeItem<T> node : nodes ) { | |
| 269 | node.setExpanded( false ); | |
| 270 | collapse( node.getChildren() ); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 276 | */ | |
| 277 | private boolean isEditingTreeItem() { | |
| 278 | return getTreeView().editingItemProperty().getValue() != null; | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Changes to edit mode for the selected item. | |
| 283 | */ | |
| 284 | private void editSelectedItem() { | |
| 285 | getTreeView().edit( getSelectedItem() ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Removes all selected items from the {@link TreeView}. | |
| 290 | */ | |
| 291 | private void deleteSelectedItems() { | |
| 292 | for( final TreeItem<String> item : getSelectedItems() ) { | |
| 293 | final TreeItem<String> parent = item.getParent(); | |
| 294 | ||
| 295 | if( parent != null ) { | |
| 296 | parent.getChildren().remove( item ); | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Deletes the selected item. | |
| 303 | */ | |
| 304 | private void deleteSelectedItem() { | |
| 305 | final TreeItem<String> c = getSelectedItem(); | |
| 306 | getSiblings( c ).remove( c ); | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 311 | * There are a few conditions to consider: when adding to the root, | |
| 312 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 313 | * root must contain two items: a key and a value. | |
| 314 | */ | |
| 315 | private void addItem() { | |
| 316 | final TreeItem<String> value = createTreeItem(); | |
| 317 | getSelectedItem().getChildren().add( value ); | |
| 318 | expand( value ); | |
| 319 | select( value ); | |
| 320 | } | |
| 321 | ||
| 322 | private ContextMenu createContextMenu() { | |
| 323 | final ContextMenu menu = new ContextMenu(); | |
| 324 | final ObservableList<MenuItem> items = menu.getItems(); | |
| 325 | ||
| 326 | addMenuItem( items, "Definition.menu.create" ) | |
| 327 | .setOnAction( e -> addItem() ); | |
| 328 | ||
| 329 | addMenuItem( items, "Definition.menu.rename" ) | |
| 330 | .setOnAction( e -> editSelectedItem() ); | |
| 331 | ||
| 332 | addMenuItem( items, "Definition.menu.remove" ) | |
| 333 | .setOnAction( e -> deleteSelectedItem() ); | |
| 334 | ||
| 335 | return menu; | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Executes hot-keys for edits to the definition tree. | |
| 340 | * | |
| 341 | * @param event Contains the key code of the key that was pressed. | |
| 342 | */ | |
| 343 | private void keyEventFilter( final KeyEvent event ) { | |
| 344 | if( !isEditingTreeItem() ) { | |
| 345 | switch( event.getCode() ) { | |
| 346 | case ENTER: | |
| 347 | expand( getSelectedItem() ); | |
| 348 | event.consume(); | |
| 349 | break; | |
| 350 | ||
| 351 | case DELETE: | |
| 352 | deleteSelectedItems(); | |
| 353 | break; | |
| 354 | ||
| 355 | case INSERT: | |
| 356 | addItem(); | |
| 357 | break; | |
| 358 | ||
| 359 | case R: | |
| 360 | if( event.isControlDown() ) { | |
| 361 | editSelectedItem(); | |
| 362 | } | |
| 363 | ||
| 364 | break; | |
| 365 | } | |
| 366 | ||
| 367 | for( final var handler : getKeyEventHandlers() ) { | |
| 368 | handler.handle( event ); | |
| 369 | } | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Adds a menu item to a list of menu items. | |
| 375 | * | |
| 376 | * @param items The list of menu items to append to. | |
| 377 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 378 | * @return The menu item added to the list of menu items. | |
| 379 | */ | |
| 380 | private MenuItem addMenuItem( | |
| 381 | final List<MenuItem> items, final String labelKey ) { | |
| 382 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 383 | items.add( menuItem ); | |
| 384 | return menuItem; | |
| 385 | } | |
| 386 | ||
| 387 | private MenuItem createMenuItem( final String labelKey ) { | |
| 388 | return new MenuItem( get( labelKey ) ); | |
| 389 | } | |
| 390 | ||
| 391 | private VariableTreeItem<String> createTreeItem() { | |
| 392 | return new VariableTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 393 | } | |
| 394 | ||
| 395 | private TreeCell<String> createTreeCell() { | |
| 396 | return new TextFieldTreeCell<>( | |
| 397 | createStringConverter() ) { | |
| 398 | @Override | |
| 399 | public void commitEdit( final String newValue ) { | |
| 400 | super.commitEdit( newValue ); | |
| 401 | select( getTreeItem() ); | |
| 402 | requestFocus(); | |
| 403 | } | |
| 404 | }; | |
| 405 | } | |
| 406 | ||
| 407 | @Override | |
| 408 | public void requestFocus() { | |
| 409 | super.requestFocus(); | |
| 410 | getTreeView().requestFocus(); | |
| 411 | } | |
| 412 | ||
| 413 | private StringConverter<String> createStringConverter() { | |
| 414 | return new StringConverter<>() { | |
| 415 | @Override | |
| 416 | public String toString( final String object ) { | |
| 417 | return object == null ? "" : object; | |
| 418 | } | |
| 419 | ||
| 420 | @Override | |
| 421 | public String fromString( final String string ) { | |
| 422 | return string == null ? "" : string; | |
| 423 | } | |
| 424 | }; | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Returns the tree view that contains the definition hierarchy. | |
| 429 | * | |
| 430 | * @return A non-null instance. | |
| 431 | */ | |
| 432 | public TreeView<String> getTreeView() { | |
| 433 | return mTreeView; | |
| 434 | } | |
| 435 | ||
| 436 | /** | |
| 437 | * Returns this pane. | |
| 438 | * | |
| 439 | * @return this | |
| 440 | */ | |
| 441 | public Node getNode() { | |
| 442 | return this; | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Returns the property used to set the title of the pane: the file name. | |
| 447 | * | |
| 448 | * @return A non-null property used for showing the definition file name. | |
| 449 | */ | |
| 450 | public StringProperty filenameProperty() { | |
| 451 | return mFilename; | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Returns the root of the tree. | |
| 456 | * | |
| 457 | * @return The first node added to the definition tree. | |
| 458 | */ | |
| 459 | private VariableTreeItem<String> getTreeRoot() { | |
| 460 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 461 | ||
| 462 | return root instanceof VariableTreeItem ? | |
| 463 | (VariableTreeItem<String>) root : new VariableTreeItem<>( "root" ); | |
| 464 | } | |
| 465 | ||
| 466 | private ObservableList<TreeItem<String>> getSiblings( | |
| 467 | final TreeItem<String> item ) { | |
| 468 | final TreeItem<String> root = getTreeView().getRoot(); | |
| 469 | final TreeItem<String> parent = | |
| 470 | (item == null || item == root) ? root : item.getParent(); | |
| 471 | ||
| 472 | return parent.getChildren(); | |
| 473 | } | |
| 474 | ||
| 475 | private MultipleSelectionModel<TreeItem<String>> getSelectionModel() { | |
| 476 | return getTreeView().getSelectionModel(); | |
| 477 | } | |
| 478 | ||
| 479 | /** | |
| 480 | * Returns a copy of all the selected items. | |
| 481 | * | |
| 482 | * @return A list, possibly empty, containing all selected items in the | |
| 483 | * {@link TreeView}. | |
| 484 | */ | |
| 485 | private List<TreeItem<String>> getSelectedItems() { | |
| 486 | return new ArrayList<>( getSelectionModel().getSelectedItems() ); | |
| 487 | 487 | } |
| 488 | 488 |
| 29 | 29 | |
| 30 | 30 | import com.scrivenvar.AbstractPane; |
| 31 | ||
| 32 | import java.nio.file.Path; | |
| 33 | import java.util.function.Consumer; | |
| 34 | ||
| 35 | 31 | import javafx.application.Platform; |
| 36 | 32 | import javafx.beans.property.ObjectProperty; |
| 37 | 33 | import javafx.beans.property.SimpleObjectProperty; |
| 38 | 34 | import javafx.beans.value.ChangeListener; |
| 39 | 35 | import javafx.event.Event; |
| 40 | 36 | import javafx.scene.control.ScrollPane; |
| 41 | import javafx.scene.input.InputEvent; | |
| 42 | 37 | import org.fxmisc.flowless.VirtualizedScrollPane; |
| 43 | 38 | import org.fxmisc.richtext.StyleClassedTextArea; |
| 44 | 39 | import org.fxmisc.undo.UndoManager; |
| 45 | 40 | import org.fxmisc.wellbehaved.event.EventPattern; |
| 46 | import org.fxmisc.wellbehaved.event.InputMap; | |
| 41 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 47 | 42 | |
| 48 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 43 | import java.nio.file.Path; | |
| 44 | import java.util.function.Consumer; | |
| 49 | 45 | |
| 50 | import org.fxmisc.wellbehaved.event.Nodes; | |
| 46 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 51 | 47 | |
| 52 | 48 | /** |
| ... | ||
| 62 | 58 | new VirtualizedScrollPane<>( mEditor ); |
| 63 | 59 | private final ObjectProperty<Path> mPath = new SimpleObjectProperty<>(); |
| 64 | ||
| 65 | /** | |
| 66 | * Set when entering variable edit mode; retrieved upon exiting. | |
| 67 | */ | |
| 68 | private InputMap<InputEvent> mNodeMap; | |
| 69 | 60 | |
| 70 | 61 | public EditorPane() { |
| ... | ||
| 94 | 85 | |
| 95 | 86 | public void setText( final String text ) { |
| 96 | getEditor().deselect(); | |
| 97 | getEditor().replaceText( text ); | |
| 87 | final var editor = getEditor(); | |
| 88 | editor.deselect(); | |
| 89 | editor.replaceText( text ); | |
| 98 | 90 | getUndoManager().mark(); |
| 99 | 91 | } |
| ... | ||
| 131 | 123 | final Consumer<? super U> consumer ) { |
| 132 | 124 | Nodes.addInputMap( getEditor(), consume( event, consumer ) ); |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * This method adds listeners to editor events that can be removed without | |
| 137 | * affecting the original listeners (i.e., the original lister is restored on | |
| 138 | * a call to removeEventListener). | |
| 139 | * | |
| 140 | * @param map The map of methods to events. | |
| 141 | */ | |
| 142 | @SuppressWarnings("unchecked") | |
| 143 | public void addEventListener( final InputMap<InputEvent> map ) { | |
| 144 | mNodeMap = (InputMap<InputEvent>) getInputMap(); | |
| 145 | Nodes.addInputMap( getEditor(), map ); | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * This method removes listeners to editor events and restores the default | |
| 150 | * handler. | |
| 151 | * | |
| 152 | * @param map The map of methods to events. | |
| 153 | */ | |
| 154 | public void removeEventListener( final InputMap<InputEvent> map ) { | |
| 155 | Nodes.removeInputMap( getEditor(), map ); | |
| 156 | Nodes.addInputMap( getEditor(), mNodeMap ); | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Returns the value for "org.fxmisc.wellbehaved.event.inputmap". | |
| 161 | * | |
| 162 | * @return An input map of input events. | |
| 163 | */ | |
| 164 | private Object getInputMap() { | |
| 165 | return getEditor().getProperties().get( getInputMapKey() ); | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Returns the hashmap key entry for the input map. | |
| 170 | * | |
| 171 | * @return "org.fxmisc.wellbehaved.event.inputmap" | |
| 172 | */ | |
| 173 | private String getInputMapKey() { | |
| 174 | return "org.fxmisc.wellbehaved.event.inputmap"; | |
| 175 | 125 | } |
| 176 | 126 | |
| 31 | 31 | import com.scrivenvar.dialogs.LinkDialog; |
| 32 | 32 | import com.scrivenvar.editors.EditorPane; |
| 33 | import com.scrivenvar.processors.MarkdownProcessor; | |
| 33 | import com.scrivenvar.processors.markdown.MarkdownProcessor; | |
| 34 | 34 | import com.vladsch.flexmark.ast.Link; |
| 35 | 35 | import com.vladsch.flexmark.util.ast.Node; |
| 28 | 28 | package com.scrivenvar.preferences; |
| 29 | 29 | |
| 30 | import com.scrivenvar.Services; | |
| 31 | import com.scrivenvar.service.events.Notifier; | |
| 32 | ||
| 30 | 33 | import java.io.File; |
| 31 | 34 | import java.io.FileInputStream; |
| ... | ||
| 44 | 47 | */ |
| 45 | 48 | public class FilePreferences extends AbstractPreferences { |
| 49 | private final Notifier mNotifier = Services.load( Notifier.class ); | |
| 46 | 50 | |
| 47 | 51 | private final Map<String, String> mRoot = new TreeMap<>(); |
| 48 | 52 | private final Map<String, FilePreferences> mChildren = new TreeMap<>(); |
| 49 | 53 | private boolean mRemoved; |
| 50 | 54 | |
| 51 | 55 | private final Object mMutex = new Object(); |
| 52 | 56 | |
| 53 | public FilePreferences( final AbstractPreferences parent, | |
| 54 | final String name ) { | |
| 57 | public FilePreferences( | |
| 58 | final AbstractPreferences parent, final String name ) { | |
| 55 | 59 | super( parent, name ); |
| 56 | 60 | |
| ... | ||
| 136 | 140 | |
| 137 | 141 | final String path = getPath(); |
| 138 | final Enumeration<?> pnen = p.propertyNames(); | |
| 142 | final Enumeration<?> propertyNames = p.propertyNames(); | |
| 139 | 143 | |
| 140 | while( pnen.hasMoreElements() ) { | |
| 141 | final String propKey = (String) pnen.nextElement(); | |
| 144 | while( propertyNames.hasMoreElements() ) { | |
| 145 | final String propKey = (String) propertyNames.nextElement(); | |
| 142 | 146 | |
| 143 | 147 | if( propKey.startsWith( path ) ) { |
| ... | ||
| 178 | 182 | |
| 179 | 183 | // Make a list of all direct children of this node to be removed |
| 180 | final Enumeration<?> pnen = p.propertyNames(); | |
| 184 | final Enumeration<?> propertyNames = p.propertyNames(); | |
| 181 | 185 | |
| 182 | while( pnen.hasMoreElements() ) { | |
| 183 | String propKey = (String) pnen.nextElement(); | |
| 186 | while( propertyNames.hasMoreElements() ) { | |
| 187 | final String propKey = (String) propertyNames.nextElement(); | |
| 184 | 188 | if( propKey.startsWith( path ) ) { |
| 185 | 189 | final String subKey = propKey.substring( path.length() ); |
| ... | ||
| 213 | 217 | |
| 214 | 218 | private void error( final BackingStoreException ex ) { |
| 215 | throw new RuntimeException( ex ); | |
| 219 | getNotifier().notify( ex ); | |
| 220 | } | |
| 221 | ||
| 222 | private Notifier getNotifier() { | |
| 223 | return mNotifier; | |
| 216 | 224 | } |
| 217 | 225 | } |
| 1 | /* | |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.preferences; | |
| 29 | ||
| 30 | import com.dlsc.formsfx.model.structure.StringField; | |
| 31 | import com.dlsc.preferencesfx.PreferencesFx; | |
| 32 | import com.dlsc.preferencesfx.model.Category; | |
| 33 | import com.dlsc.preferencesfx.model.Group; | |
| 34 | import com.dlsc.preferencesfx.model.Setting; | |
| 35 | import com.scrivenvar.Services; | |
| 36 | import com.scrivenvar.service.Settings; | |
| 37 | import javafx.beans.property.ObjectProperty; | |
| 38 | import javafx.beans.property.SimpleObjectProperty; | |
| 39 | import javafx.beans.property.SimpleStringProperty; | |
| 40 | import javafx.beans.property.StringProperty; | |
| 41 | import javafx.scene.Node; | |
| 42 | import javafx.scene.control.Label; | |
| 43 | ||
| 44 | import java.io.File; | |
| 45 | import java.nio.file.Path; | |
| 46 | ||
| 47 | import static com.scrivenvar.Constants.PERSIST_IMAGES_DEFAULT; | |
| 48 | import static com.scrivenvar.Constants.USER_DIRECTORY; | |
| 49 | import static com.scrivenvar.Messages.get; | |
| 50 | ||
| 51 | public class UserPreferences { | |
| 52 | private final Settings SETTINGS = Services.load( Settings.class ); | |
| 53 | ||
| 54 | private final ObjectProperty<File> mPropRDirectory; | |
| 55 | private final StringProperty mPropRScript; | |
| 56 | private final ObjectProperty<File> mPropImagesDirectory; | |
| 57 | private final StringProperty mPropImagesOrder; | |
| 58 | private final ObjectProperty<File> mPropDefinitionPath; | |
| 59 | ||
| 60 | private final PreferencesFx mPreferencesFx; | |
| 61 | ||
| 62 | public UserPreferences() { | |
| 63 | mPropRDirectory = simpleFile( USER_DIRECTORY ); | |
| 64 | mPropRScript = new SimpleStringProperty( "" ); | |
| 65 | ||
| 66 | mPropImagesDirectory = simpleFile( USER_DIRECTORY ); | |
| 67 | mPropImagesOrder = new SimpleStringProperty( PERSIST_IMAGES_DEFAULT ); | |
| 68 | ||
| 69 | mPropDefinitionPath = simpleFile( getSetting( | |
| 70 | "file.definition.default", "variables.yaml" ) | |
| 71 | ); | |
| 72 | ||
| 73 | mPreferencesFx = createPreferencesFx(); | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * Display the user preferences settings dialog (non-modal). | |
| 78 | */ | |
| 79 | public void show() { | |
| 80 | mPreferencesFx.show( false ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Call to persist the settings. Strictly speaking, this could watch on | |
| 85 | * all values for external changes then save automatically. | |
| 86 | */ | |
| 87 | public void save() { | |
| 88 | mPreferencesFx.saveSettings(); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Creates the preferences dialog. | |
| 93 | * <p> | |
| 94 | * TODO: Make this dynamic by iterating over all "Preferences.*" values | |
| 95 | * that follow a particular naming pattern. | |
| 96 | * </p> | |
| 97 | * | |
| 98 | * @return A new instance of preferences for users to edit. | |
| 99 | */ | |
| 100 | @SuppressWarnings("unchecked") | |
| 101 | private PreferencesFx createPreferencesFx() { | |
| 102 | final Setting<StringField, StringProperty> scriptSetting = | |
| 103 | Setting.of( "Script", mPropRScript ); | |
| 104 | final StringField field = scriptSetting.getElement(); | |
| 105 | field.multiline( true ); | |
| 106 | ||
| 107 | return PreferencesFx.of( | |
| 108 | UserPreferences.class, | |
| 109 | Category.of( | |
| 110 | get( "Preferences.r" ), | |
| 111 | Group.of( | |
| 112 | get( "Preferences.r.directory" ), | |
| 113 | Setting.of( label( "Preferences.r.directory.desc", false ) ), | |
| 114 | Setting.of( "Directory", mPropRDirectory, true ) | |
| 115 | ), | |
| 116 | Group.of( | |
| 117 | get( "Preferences.r.script" ), | |
| 118 | Setting.of( label( "Preferences.r.script.desc" ) ), | |
| 119 | scriptSetting | |
| 120 | ) | |
| 121 | ), | |
| 122 | Category.of( | |
| 123 | get( "Preferences.images" ), | |
| 124 | Group.of( | |
| 125 | get( "Preferences.images.directory" ), | |
| 126 | Setting.of( label( "Preferences.images.directory.desc" ) ), | |
| 127 | Setting.of( "Directory", mPropImagesDirectory, true ) | |
| 128 | ), | |
| 129 | Group.of( | |
| 130 | get( "Preferences.images.suffixes" ), | |
| 131 | Setting.of( label( "Preferences.images.suffixes.desc" ) ), | |
| 132 | Setting.of( "Extensions", mPropImagesOrder ) | |
| 133 | ) | |
| 134 | ), | |
| 135 | Category.of( | |
| 136 | get( "Preferences.definitions" ), | |
| 137 | Group.of( | |
| 138 | get( "Preferences.definitions.path" ), | |
| 139 | Setting.of( label( "Preferences.definitions.path.desc" ) ), | |
| 140 | Setting.of( "Path", mPropDefinitionPath, false ) | |
| 141 | ) | |
| 142 | ) | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Wraps a {@link File} inside a {@link SimpleObjectProperty}. | |
| 148 | * | |
| 149 | * @param path The file name to use when constructing the {@link File}. | |
| 150 | * @return A new {@link SimpleObjectProperty} instance with a {@link File} | |
| 151 | * that references the given {@code path}. | |
| 152 | */ | |
| 153 | private SimpleObjectProperty<File> simpleFile( final String path ) { | |
| 154 | return new SimpleObjectProperty<>( new File( path ) ); | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Creates a label for the given key after interpolating its value. | |
| 159 | * | |
| 160 | * @param key The key to find in the resource bundle. | |
| 161 | * @return The value of the key as a label. | |
| 162 | */ | |
| 163 | private Node label( final String key ) { | |
| 164 | return new Label( get( key, true ) ); | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * Creates a label for the given key. | |
| 169 | * | |
| 170 | * @param key The key to find in the resource bundle. | |
| 171 | * @param interpolate {@code true} means to interpolate the value. | |
| 172 | * @return The value of the key, interpolated if {@code interpolate} is | |
| 173 | * {@code true}. | |
| 174 | */ | |
| 175 | @SuppressWarnings("SameParameterValue") | |
| 176 | private Node label( final String key, final boolean interpolate ) { | |
| 177 | return new Label( get( key, interpolate ) ); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Returns the value for a key from the settings properties file. | |
| 182 | * | |
| 183 | * @param key Key within the settings properties file to find. | |
| 184 | * @param value Default value to return if the key is not found. | |
| 185 | * @return The value for the given key from the settings file, or the | |
| 186 | * given {@code value} if no key found. | |
| 187 | */ | |
| 188 | @SuppressWarnings("SameParameterValue") | |
| 189 | private String getSetting( final String key, final String value ) { | |
| 190 | return SETTINGS.getSetting( key, value ); | |
| 191 | } | |
| 192 | ||
| 193 | public ObjectProperty<File> definitionPathProperty() { | |
| 194 | return mPropDefinitionPath; | |
| 195 | } | |
| 196 | ||
| 197 | public Path getDefinitionPath() { | |
| 198 | return definitionPathProperty().getValue().toPath(); | |
| 199 | } | |
| 200 | ||
| 201 | private ObjectProperty<File> rDirectoryProperty() { | |
| 202 | return mPropRDirectory; | |
| 203 | } | |
| 204 | ||
| 205 | public File getRDirectory() { | |
| 206 | return rDirectoryProperty().getValue(); | |
| 207 | } | |
| 208 | ||
| 209 | private StringProperty rScriptProperty() { | |
| 210 | return mPropRScript; | |
| 211 | } | |
| 212 | ||
| 213 | public String getRScript() { | |
| 214 | return rScriptProperty().getValue(); | |
| 215 | } | |
| 216 | ||
| 217 | private ObjectProperty<File> imagesDirectoryProperty() { | |
| 218 | return mPropImagesDirectory; | |
| 219 | } | |
| 220 | ||
| 221 | public File getImagesDirectory() { | |
| 222 | return imagesDirectoryProperty().getValue(); | |
| 223 | } | |
| 224 | ||
| 225 | private StringProperty imagesOrderProperty() { | |
| 226 | return mPropImagesOrder; | |
| 227 | } | |
| 228 | ||
| 229 | public String getImagesOrder() { | |
| 230 | return imagesOrderProperty().getValue(); | |
| 231 | } | |
| 232 | } | |
| 1 | 233 |
| 1 | /* | |
| 2 | * {{{ header & license | |
| 3 | * Copyright (c) 2006 Patrick Wright | |
| 4 | * Copyright (c) 2007 Wisconsin Court System | |
| 5 | * | |
| 6 | * This program is free software; you can redistribute it and/or | |
| 7 | * modify it under the terms of the GNU Lesser General Public License | |
| 8 | * as published by the Free Software Foundation; either version 2.1 | |
| 9 | * of the License, or (at your option) any later version. | |
| 10 | * | |
| 11 | * This program is distributed in the hope that it will be useful, | |
| 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 14 | * GNU Lesser General Public License for more details. | |
| 15 | * | |
| 16 | * You should have received a copy of the GNU Lesser General Public License | |
| 17 | * along with this program; if not, write to the Free Software | |
| 18 | * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. | |
| 19 | * }}} | |
| 20 | */ | |
| 21 | package com.scrivenvar.preview; | |
| 22 | ||
| 23 | import org.w3c.dom.Element; | |
| 24 | import org.xhtmlrenderer.extend.ReplacedElement; | |
| 25 | import org.xhtmlrenderer.extend.ReplacedElementFactory; | |
| 26 | import org.xhtmlrenderer.extend.UserAgentCallback; | |
| 27 | import org.xhtmlrenderer.layout.LayoutContext; | |
| 28 | import org.xhtmlrenderer.render.BlockBox; | |
| 29 | import org.xhtmlrenderer.simple.extend.FormSubmissionListener; | |
| 30 | ||
| 31 | import java.util.ArrayList; | |
| 32 | import java.util.List; | |
| 33 | ||
| 34 | public class ChainedReplacedElementFactory implements ReplacedElementFactory { | |
| 35 | private final List<ReplacedElementFactory> mFactoryList = new ArrayList<>(); | |
| 36 | ||
| 37 | public ChainedReplacedElementFactory() { | |
| 38 | } | |
| 39 | ||
| 40 | public ReplacedElement createReplacedElement( | |
| 41 | LayoutContext c, BlockBox box, UserAgentCallback uac, | |
| 42 | int cssWidth, int cssHeight ) { | |
| 43 | ReplacedElement re = null; | |
| 44 | ||
| 45 | for( final ReplacedElementFactory ref : mFactoryList ) { | |
| 46 | re = ref.createReplacedElement( c, box, uac, cssWidth, cssHeight ); | |
| 47 | ||
| 48 | if( re != null ) { | |
| 49 | break; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | return re; | |
| 54 | } | |
| 55 | ||
| 56 | public void addFactory( final ReplacedElementFactory factory ) { | |
| 57 | mFactoryList.add( factory ); | |
| 58 | } | |
| 59 | ||
| 60 | public void reset() { | |
| 61 | for( final ReplacedElementFactory factory : mFactoryList ) { | |
| 62 | factory.reset(); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | public void remove( final Element element ) { | |
| 67 | for( final ReplacedElementFactory factory : mFactoryList ) { | |
| 68 | factory.remove( element ); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | public void setFormSubmissionListener( FormSubmissionListener listener ) { | |
| 73 | } | |
| 74 | } | |
| 1 | 75 |
| 28 | 28 | package com.scrivenvar.preview; |
| 29 | 29 | |
| 30 | import static com.scrivenvar.Constants.CARET_POSITION_BASE; | |
| 31 | import static com.scrivenvar.Constants.STYLESHEET_PREVIEW; | |
| 30 | import javafx.embed.swing.SwingNode; | |
| 31 | import javafx.scene.Node; | |
| 32 | import javafx.scene.layout.Pane; | |
| 33 | import org.jsoup.Jsoup; | |
| 34 | import org.jsoup.helper.W3CDom; | |
| 35 | import org.jsoup.nodes.Document; | |
| 36 | import org.xhtmlrenderer.simple.XHTMLPanel; | |
| 37 | import org.xhtmlrenderer.simple.extend.XhtmlNamespaceHandler; | |
| 38 | import org.xhtmlrenderer.swing.SwingReplacedElementFactory; | |
| 32 | 39 | |
| 40 | import javax.swing.*; | |
| 33 | 41 | import java.nio.file.Path; |
| 34 | ||
| 35 | import javafx.beans.value.ObservableValue; | |
| 36 | import javafx.concurrent.Worker.State; | |
| 37 | ||
| 38 | import static javafx.concurrent.Worker.State.SUCCEEDED; | |
| 39 | 42 | |
| 40 | import javafx.scene.Node; | |
| 41 | import javafx.scene.layout.Pane; | |
| 42 | import javafx.scene.web.WebEngine; | |
| 43 | import javafx.scene.web.WebView; | |
| 43 | import static com.scrivenvar.Constants.STYLESHEET_PREVIEW; | |
| 44 | 44 | |
| 45 | 45 | /** |
| 46 | 46 | * HTML preview pane is responsible for rendering an HTML document. |
| 47 | 47 | * |
| 48 | 48 | * @author Karl Tauber and White Magic Software, Ltd. |
| 49 | 49 | */ |
| 50 | 50 | public final class HTMLPreviewPane extends Pane { |
| 51 | private static class HTMLPanel extends XHTMLPanel { | |
| 52 | /** | |
| 53 | * Prevent scrolling to the top. | |
| 54 | */ | |
| 55 | @Override | |
| 56 | public void resetScrollPosition() { | |
| 57 | } | |
| 58 | } | |
| 51 | 59 | |
| 52 | private final WebView webView = new WebView(); | |
| 53 | private Path path; | |
| 60 | private final static String HTML_HEADER = "<!DOCTYPE html>" | |
| 61 | + "<html>" | |
| 62 | + "<head>" | |
| 63 | + "<link rel='stylesheet' href='" + | |
| 64 | HTMLPreviewPane.class.getResource( STYLESHEET_PREVIEW ) + "'/>" | |
| 65 | + "</head>" | |
| 66 | + "<body>"; | |
| 67 | private final static String HTML_FOOTER = "</body></html>"; | |
| 68 | ||
| 69 | private final StringBuilder mHtml = new StringBuilder( 65536 ); | |
| 70 | private final int mHtmlPrefixLength; | |
| 71 | ||
| 72 | private final W3CDom mW3cDom = new W3CDom(); | |
| 73 | private final XhtmlNamespaceHandler mNamespaceHandler = | |
| 74 | new XhtmlNamespaceHandler(); | |
| 75 | private final HTMLPanel mRenderer = new HTMLPanel(); | |
| 76 | private final SwingNode mSwingNode = new SwingNode(); | |
| 77 | private final JScrollPane mScrollPane = new JScrollPane( mRenderer ); | |
| 78 | ||
| 79 | private Path mPath; | |
| 54 | 80 | |
| 55 | 81 | /** |
| 56 | 82 | * Creates a new preview pane that can scroll to the caret position within the |
| 57 | 83 | * document. |
| 58 | 84 | */ |
| 59 | 85 | public HTMLPreviewPane() { |
| 60 | initListeners(); | |
| 61 | initTraversal(); | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * Initializes observers for document changes. When the document is reloaded | |
| 66 | * with new HTML, this triggers a scroll event that repositions the document | |
| 67 | * to the injected caret (that corresponds with the position in the text | |
| 68 | * editor). | |
| 69 | */ | |
| 70 | private void initListeners() { | |
| 71 | // Scrolls to the caret after the content has been loaded. | |
| 72 | getEngine().getLoadWorker().stateProperty().addListener( | |
| 73 | ( ObservableValue<? extends State> observable, | |
| 74 | final State oldValue, final State newValue ) -> { | |
| 75 | if( newValue == SUCCEEDED ) { | |
| 76 | scrollToCaret(); | |
| 77 | } | |
| 78 | } ); | |
| 79 | } | |
| 86 | final ChainedReplacedElementFactory factory = | |
| 87 | new ChainedReplacedElementFactory(); | |
| 88 | factory.addFactory( new SVGReplacedElementFactory() ); | |
| 89 | factory.addFactory( new SwingReplacedElementFactory() ); | |
| 80 | 90 | |
| 81 | /** | |
| 82 | * Ensures images can be found relative to the document. | |
| 83 | * | |
| 84 | * @return The base path element to use for the document, or the empty string | |
| 85 | * if no path has been set, yet. | |
| 86 | */ | |
| 87 | private String getBase() { | |
| 88 | final Path basePath = getPath(); | |
| 89 | final Path parent = basePath == null ? null : basePath.getParent(); | |
| 91 | mRenderer.getSharedContext().setReplacedElementFactory( factory ); | |
| 92 | mRenderer.getSharedContext().getTextRenderer().setSmoothingThreshold( 0 ); | |
| 93 | mSwingNode.setContent( mScrollPane ); | |
| 90 | 94 | |
| 91 | return parent == null | |
| 92 | ? "" | |
| 93 | : ("<base href='" + parent.toUri().toString() + "'>"); | |
| 95 | mHtml.append( HTML_HEADER ); | |
| 96 | mHtmlPrefixLength = mHtml.length(); | |
| 94 | 97 | } |
| 95 | 98 | |
| 96 | 99 | /** |
| 97 | 100 | * Updates the internal HTML source, loads it into the preview pane, then |
| 98 | 101 | * scrolls to the caret position. |
| 99 | 102 | * |
| 100 | 103 | * @param html The new HTML document to display. |
| 101 | 104 | */ |
| 102 | 105 | public void update( final String html ) { |
| 103 | getEngine().loadContent( | |
| 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>" ); | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Clears out the HTML content from the preview. | |
| 119 | */ | |
| 120 | public void clear() { | |
| 121 | update( "" ); | |
| 122 | } | |
| 106 | final Document jsoupDoc = Jsoup.parse( decorate( html ) ); | |
| 107 | org.w3c.dom.Document w3cDoc = mW3cDom.fromJsoup( jsoupDoc ); | |
| 123 | 108 | |
| 124 | /** | |
| 125 | * Scrolls to the caret position in the document. | |
| 126 | */ | |
| 127 | private void scrollToCaret() { | |
| 128 | execute( getScrollScript() ); | |
| 109 | mRenderer.setDocument( w3cDoc, getBaseUrl(), mNamespaceHandler ); | |
| 129 | 110 | } |
| 130 | 111 | |
| 131 | /** | |
| 132 | * Returns the JavaScript used to scroll the WebView pane. | |
| 133 | * | |
| 134 | * @return A script that tries to center the view port on the CARET POSITION. | |
| 135 | */ | |
| 136 | private String getScrollScript() { | |
| 137 | return "" | |
| 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 | + "}"; | |
| 112 | private String decorate( final String html ) { | |
| 113 | mHtml.setLength( mHtmlPrefixLength ); | |
| 114 | return mHtml.append( html ) | |
| 115 | .append( HTML_FOOTER ) | |
| 116 | .toString(); | |
| 146 | 117 | } |
| 147 | 118 | |
| 148 | 119 | /** |
| 149 | * Prevent tabbing into the preview pane. | |
| 120 | * Clears out the HTML content from the preview. | |
| 150 | 121 | */ |
| 151 | private void initTraversal() { | |
| 152 | getWebView().setFocusTraversable( false ); | |
| 153 | } | |
| 154 | ||
| 155 | private void execute( final String script ) { | |
| 156 | getEngine().executeScript( script ); | |
| 122 | public void clear() { | |
| 123 | update( "" ); | |
| 157 | 124 | } |
| 158 | 125 | |
| 159 | private WebEngine getEngine() { | |
| 160 | return getWebView().getEngine(); | |
| 161 | } | |
| 126 | private String getBaseUrl() { | |
| 127 | final Path basePath = getPath(); | |
| 128 | final Path parent = basePath == null ? null : basePath.getParent(); | |
| 162 | 129 | |
| 163 | private WebView getWebView() { | |
| 164 | return this.webView; | |
| 130 | return parent == null ? "" : parent.toUri().toString(); | |
| 165 | 131 | } |
| 166 | 132 | |
| 167 | private Path getPath() { | |
| 168 | return this.path; | |
| 133 | public Path getPath() { | |
| 134 | return mPath; | |
| 169 | 135 | } |
| 170 | 136 | |
| 171 | 137 | public void setPath( final Path path ) { |
| 172 | 138 | assert path != null; |
| 173 | ||
| 174 | this.path = path; | |
| 139 | mPath = path; | |
| 175 | 140 | } |
| 176 | 141 | |
| 177 | 142 | /** |
| 178 | 143 | * Content to embed in a panel. |
| 179 | 144 | * |
| 180 | 145 | * @return The content to display to the user. |
| 181 | 146 | */ |
| 182 | 147 | public Node getNode() { |
| 183 | return getWebView(); | |
| 148 | return mSwingNode; | |
| 149 | } | |
| 150 | ||
| 151 | public JScrollPane getScrollPane() { | |
| 152 | return mScrollPane; | |
| 153 | } | |
| 154 | ||
| 155 | public JScrollBar getVerticalScrollBar() { | |
| 156 | return getScrollPane().getVerticalScrollBar(); | |
| 184 | 157 | } |
| 185 | 158 | } |
| 1 | /* | |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.preview; | |
| 29 | ||
| 30 | import org.apache.batik.anim.dom.SAXSVGDocumentFactory; | |
| 31 | import org.apache.batik.gvt.renderer.ImageRenderer; | |
| 32 | import org.apache.batik.transcoder.TranscoderException; | |
| 33 | import org.apache.batik.transcoder.TranscoderInput; | |
| 34 | import org.apache.batik.transcoder.TranscoderOutput; | |
| 35 | import org.apache.batik.transcoder.image.ImageTranscoder; | |
| 36 | import org.w3c.dom.svg.SVGDocument; | |
| 37 | ||
| 38 | import java.awt.*; | |
| 39 | import java.awt.image.BufferedImage; | |
| 40 | import java.io.IOException; | |
| 41 | import java.net.URL; | |
| 42 | import java.util.Map; | |
| 43 | ||
| 44 | import static java.awt.Color.WHITE; | |
| 45 | import static java.awt.RenderingHints.*; | |
| 46 | import static org.apache.batik.transcoder.SVGAbstractTranscoder.KEY_WIDTH; | |
| 47 | import static org.apache.batik.transcoder.image.ImageTranscoder.KEY_BACKGROUND_COLOR; | |
| 48 | import static org.apache.batik.util.XMLResourceDescriptor.getXMLParserClassName; | |
| 49 | ||
| 50 | public class SVGRasterizer { | |
| 51 | private final static SAXSVGDocumentFactory mFactory = | |
| 52 | new SAXSVGDocumentFactory( getXMLParserClassName() ); | |
| 53 | ||
| 54 | private final static Map<Object, Object> RENDERING_HINTS = Map.of( | |
| 55 | KEY_ALPHA_INTERPOLATION, | |
| 56 | VALUE_ALPHA_INTERPOLATION_QUALITY, | |
| 57 | KEY_INTERPOLATION, | |
| 58 | VALUE_INTERPOLATION_BICUBIC, | |
| 59 | KEY_ANTIALIASING, | |
| 60 | VALUE_ANTIALIAS_ON, | |
| 61 | KEY_COLOR_RENDERING, | |
| 62 | VALUE_COLOR_RENDER_QUALITY, | |
| 63 | KEY_DITHERING, | |
| 64 | VALUE_DITHER_DISABLE, | |
| 65 | KEY_RENDERING, | |
| 66 | VALUE_RENDER_QUALITY, | |
| 67 | KEY_STROKE_CONTROL, | |
| 68 | VALUE_STROKE_PURE, | |
| 69 | KEY_FRACTIONALMETRICS, | |
| 70 | VALUE_FRACTIONALMETRICS_ON, | |
| 71 | KEY_TEXT_ANTIALIASING, | |
| 72 | VALUE_TEXT_ANTIALIAS_OFF | |
| 73 | ); | |
| 74 | ||
| 75 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 76 | private BufferedImage mImage; | |
| 77 | ||
| 78 | @Override | |
| 79 | public BufferedImage createImage( final int w, final int h ) { | |
| 80 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 81 | } | |
| 82 | ||
| 83 | @Override | |
| 84 | public void writeImage( | |
| 85 | final BufferedImage image, final TranscoderOutput output ) { | |
| 86 | mImage = image; | |
| 87 | } | |
| 88 | ||
| 89 | public BufferedImage getBufferedImage() { | |
| 90 | return mImage; | |
| 91 | } | |
| 92 | ||
| 93 | @Override | |
| 94 | protected ImageRenderer createRenderer() { | |
| 95 | final ImageRenderer renderer = super.createRenderer(); | |
| 96 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 97 | hints.putAll( RENDERING_HINTS ); | |
| 98 | ||
| 99 | renderer.setRenderingHints( hints ); | |
| 100 | ||
| 101 | return renderer; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | public static BufferedImage rasterize( final String url, final int width ) | |
| 106 | throws IOException, TranscoderException { | |
| 107 | return rasterize( new URL( url ), width ); | |
| 108 | } | |
| 109 | ||
| 110 | public static BufferedImage rasterize( final URL url, final int width ) | |
| 111 | throws IOException, TranscoderException { | |
| 112 | return rasterize( | |
| 113 | (SVGDocument) mFactory.createDocument( url.toString() ), width ); | |
| 114 | } | |
| 115 | ||
| 116 | public static BufferedImage rasterize( | |
| 117 | final SVGDocument svg, final int width ) throws TranscoderException { | |
| 118 | final var transcoder = new BufferedImageTranscoder(); | |
| 119 | final var input = new TranscoderInput( svg ); | |
| 120 | ||
| 121 | transcoder.addTranscodingHint( KEY_BACKGROUND_COLOR, WHITE ); | |
| 122 | transcoder.addTranscodingHint( KEY_WIDTH, (float) width ); | |
| 123 | transcoder.transcode( input, null ); | |
| 124 | ||
| 125 | return transcoder.getBufferedImage(); | |
| 126 | } | |
| 127 | } | |
| 1 | 128 |
| 1 | /* | |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.preview; | |
| 29 | ||
| 30 | import com.scrivenvar.Services; | |
| 31 | import com.scrivenvar.service.events.Notifier; | |
| 32 | import org.apache.commons.io.FilenameUtils; | |
| 33 | import org.w3c.dom.Element; | |
| 34 | import org.xhtmlrenderer.extend.ReplacedElement; | |
| 35 | import org.xhtmlrenderer.extend.ReplacedElementFactory; | |
| 36 | import org.xhtmlrenderer.extend.UserAgentCallback; | |
| 37 | import org.xhtmlrenderer.layout.LayoutContext; | |
| 38 | import org.xhtmlrenderer.render.BlockBox; | |
| 39 | import org.xhtmlrenderer.simple.extend.FormSubmissionListener; | |
| 40 | import org.xhtmlrenderer.swing.ImageReplacedElement; | |
| 41 | ||
| 42 | import java.awt.*; | |
| 43 | ||
| 44 | public class SVGReplacedElementFactory | |
| 45 | implements ReplacedElementFactory { | |
| 46 | ||
| 47 | private final static Notifier sNotifier = Services.load( Notifier.class ); | |
| 48 | ||
| 49 | /** | |
| 50 | * SVG filename extension. | |
| 51 | */ | |
| 52 | private static final String SVG_FILE = "svg"; | |
| 53 | private static final String HTML_IMAGE = "img"; | |
| 54 | private static final String HTML_IMAGE_SRC = "src"; | |
| 55 | ||
| 56 | public ReplacedElement createReplacedElement( | |
| 57 | final LayoutContext c, final BlockBox box, final UserAgentCallback uac, | |
| 58 | final int cssWidth, final int cssHeight ) { | |
| 59 | final Element e = box.getElement(); | |
| 60 | ||
| 61 | if( e == null ) { | |
| 62 | return null; | |
| 63 | } | |
| 64 | ||
| 65 | final String nodeName = e.getNodeName(); | |
| 66 | ReplacedElement result = null; | |
| 67 | ||
| 68 | if( HTML_IMAGE.equals( nodeName ) ) { | |
| 69 | final String src = e.getAttribute( HTML_IMAGE_SRC ); | |
| 70 | final String ext = FilenameUtils.getExtension( src ); | |
| 71 | ||
| 72 | if( SVG_FILE.equalsIgnoreCase( ext ) ) { | |
| 73 | try { | |
| 74 | final int width = box.getContentWidth(); | |
| 75 | final Image image = SVGRasterizer.rasterize( src, width ); | |
| 76 | ||
| 77 | final int w = image.getWidth( null ); | |
| 78 | final int h = image.getHeight( null ); | |
| 79 | ||
| 80 | result = new ImageReplacedElement( image, w, h ); | |
| 81 | } catch( final Exception ex ) { | |
| 82 | getNotifier().notify( ex ); | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | return result; | |
| 88 | } | |
| 89 | ||
| 90 | @Override | |
| 91 | public void reset() { | |
| 92 | } | |
| 93 | ||
| 94 | @Override | |
| 95 | public void remove( Element e ) { | |
| 96 | } | |
| 97 | ||
| 98 | @Override | |
| 99 | public void setFormSubmissionListener( FormSubmissionListener listener ) { | |
| 100 | } | |
| 101 | ||
| 102 | private Notifier getNotifier() { | |
| 103 | return sNotifier; | |
| 104 | } | |
| 105 | } | |
| 1 | 106 |
| 39 | 39 | public abstract class AbstractProcessor<T> implements Processor<T> { |
| 40 | 40 | |
| 41 | protected static final char NEWLINE = '\n'; | |
| 42 | ||
| 43 | /** | |
| 44 | * When performing string searches using indexOf, a return value of -1 | |
| 45 | * indicates that the string could not be found. | |
| 46 | */ | |
| 47 | protected static final int INDEX_NOT_FOUND = -1; | |
| 48 | ||
| 49 | 41 | /** |
| 50 | 42 | * Used while processing the entire chain; null to signify no more links. |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | import static com.scrivenvar.Constants.CARET_POSITION_MD; | |
| 31 | import javafx.beans.property.IntegerProperty; | |
| 32 | import javafx.beans.property.SimpleIntegerProperty; | |
| 33 | import javafx.beans.value.ObservableValue; | |
| 34 | ||
| 35 | /** | |
| 36 | * Base class for inserting the magic CARET POSITION into the text so that, upon | |
| 37 | * previewing, the preview pane can scroll to the correct position (relative to | |
| 38 | * the caret position in the editor). | |
| 39 | * | |
| 40 | * @author White Magic Software, Ltd. | |
| 41 | */ | |
| 42 | public abstract class CaretInsertionProcessor extends AbstractProcessor<String> { | |
| 43 | ||
| 44 | private final IntegerProperty caretPosition = new SimpleIntegerProperty(); | |
| 45 | private final static String NEWLINE_CARET_POSITION_MD = NEWLINE + CARET_POSITION_MD; | |
| 46 | ||
| 47 | public CaretInsertionProcessor( | |
| 48 | final Processor<String> processor, | |
| 49 | final ObservableValue<Integer> position ) { | |
| 50 | super( processor ); | |
| 51 | this.caretPosition.bind( position ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Inserts the caret position token into the text at an offset that won't | |
| 56 | * interfere with parsing the text itself, regardless of text format. | |
| 57 | * | |
| 58 | * @param text The text document to change. | |
| 59 | * @param i The caret position token insertion point to use, or -1 to return | |
| 60 | * the text without any injection. | |
| 61 | * | |
| 62 | * @return The given text with a caret position token inserted at the given | |
| 63 | * offset. | |
| 64 | */ | |
| 65 | protected String inject( final String text, final int i ) { | |
| 66 | if( i > 0 && i <= text.length() ) { | |
| 67 | // Preserve the newline character when inserting the caret position mark. | |
| 68 | final String replacement = text.charAt( i - 1 ) == NEWLINE | |
| 69 | ? NEWLINE_CARET_POSITION_MD | |
| 70 | : CARET_POSITION_MD; | |
| 71 | ||
| 72 | return new StringBuilder( text ).replace( i, i, replacement ).toString(); | |
| 73 | } | |
| 74 | ||
| 75 | return text; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Returns true if i is greater than or equal to min and less than or equal to | |
| 80 | * max. | |
| 81 | * | |
| 82 | * @param i The value to check. | |
| 83 | * @param min The lower bound. | |
| 84 | * @param max The upper bound. | |
| 85 | * | |
| 86 | * @return false The value of i is either lower than min or greater than max. | |
| 87 | */ | |
| 88 | protected boolean isBetween( int i, int min, int max ) { | |
| 89 | return i >= min && i <= max; | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Returns the editor's caret position. | |
| 94 | * | |
| 95 | * @return Where the user has positioned the caret. | |
| 96 | */ | |
| 97 | protected int getCaretPosition() { | |
| 98 | return this.caretPosition.getValue(); | |
| 99 | } | |
| 100 | } | |
| 101 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | import static com.scrivenvar.Constants.CARET_POSITION_HTML; | |
| 31 | import static com.scrivenvar.Constants.CARET_POSITION_MD; | |
| 32 | ||
| 33 | /** | |
| 34 | * Responsible for replacing the caret position marker with an HTML element | |
| 35 | * suitable to use as a reference for scrolling a view port. | |
| 36 | * | |
| 37 | * @author White Magic Software, Ltd. | |
| 38 | */ | |
| 39 | public class CaretReplacementProcessor extends AbstractProcessor<String> { | |
| 40 | ||
| 41 | public CaretReplacementProcessor( final Processor<String> processor ) { | |
| 42 | super( processor ); | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Replaces each MD_CARET_POSITION with an HTML element that has an id | |
| 47 | * attribute of CARET_POSITION. This should only replace one item. | |
| 48 | * | |
| 49 | * @param t The text that contains | |
| 50 | * @return The value of the first instance replaced. | |
| 51 | */ | |
| 52 | @Override | |
| 53 | public String processLink( final String t ) { | |
| 54 | return replace( t, CARET_POSITION_MD, CARET_POSITION_HTML ); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Replaces the needle with thread in the given haystack. Based on Apache | |
| 59 | * Commons 3 StringUtils.replace method. Should be faster than String.replace, | |
| 60 | * which performs a little regex under the hood. | |
| 61 | * | |
| 62 | * @param haystack Search this string for the needle, must not be null. | |
| 63 | * @param needle The text to find in the haystack. | |
| 64 | * @param thread Replace the needle with this text, if the needle is found. | |
| 65 | * @return The haystack with the first instance of needle replaced with | |
| 66 | * thread. | |
| 67 | */ | |
| 68 | @SuppressWarnings("SameParameterValue") | |
| 69 | private static String replace( | |
| 70 | final String haystack, final String needle, final String thread ) { | |
| 71 | final int end = haystack.indexOf( needle ); | |
| 72 | ||
| 73 | return end == INDEX_NOT_FOUND ? | |
| 74 | haystack : | |
| 75 | haystack.substring( 0, end ) + thread + | |
| 76 | haystack.substring( end + needle.length() ); | |
| 77 | } | |
| 78 | } | |
| 79 | 1 |
| 29 | 29 | |
| 30 | 30 | import com.scrivenvar.Services; |
| 31 | import com.scrivenvar.preferences.UserPreferences; | |
| 31 | 32 | import com.scrivenvar.service.Options; |
| 32 | 33 | import com.scrivenvar.service.events.Notifier; |
| 33 | import org.renjin.eval.EvalException; | |
| 34 | 34 | |
| 35 | 35 | import javax.script.ScriptEngine; |
| 36 | 36 | import javax.script.ScriptEngineManager; |
| 37 | 37 | import javax.script.ScriptException; |
| 38 | 38 | import java.nio.file.Path; |
| 39 | import java.util.LinkedHashMap; | |
| 39 | 40 | import java.util.Map; |
| 40 | 41 | |
| ... | ||
| 56 | 57 | private static final Options OPTIONS = Services.load( Options.class ); |
| 57 | 58 | |
| 58 | // Only one editor is open at a time. | |
| 59 | /** | |
| 60 | * Constrain memory when typing new R expressions into the document. | |
| 61 | */ | |
| 62 | private static final int MAX_CACHED_R_STATEMENTS = 512; | |
| 63 | ||
| 64 | /** | |
| 65 | * Only one editor is open at a time. | |
| 66 | */ | |
| 59 | 67 | private static final ScriptEngine ENGINE = |
| 60 | 68 | (new ScriptEngineManager()).getEngineByName( "Renjin" ); |
| 69 | ||
| 70 | /** | |
| 71 | * Where to put document inline evaluated R expressions. | |
| 72 | */ | |
| 73 | private final Map<String, Object> mEvalCache = new LinkedHashMap<>() { | |
| 74 | @Override | |
| 75 | protected boolean removeEldestEntry( | |
| 76 | final Map.Entry<String, Object> eldest ) { | |
| 77 | return size() > MAX_CACHED_R_STATEMENTS; | |
| 78 | } | |
| 79 | }; | |
| 61 | 80 | |
| 62 | 81 | /** |
| ... | ||
| 83 | 102 | map.put( "$application.r.working.directory$", dir ); |
| 84 | 103 | |
| 85 | final String initScript = getInitScript(); | |
| 104 | final String bootstrap = getBootstrapScript(); | |
| 86 | 105 | |
| 87 | if( !initScript.isBlank() ) { | |
| 88 | eval( replace( initScript, map ) ); | |
| 106 | if( !bootstrap.isBlank() ) { | |
| 107 | eval( replace( bootstrap, map ) ); | |
| 89 | 108 | } |
| 90 | 109 | } catch( final Exception e ) { |
| 91 | 110 | getNotifier().notify( e ); |
| 92 | 111 | } |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * Loads the R init script from the application's persisted preferences. | |
| 97 | * | |
| 98 | * @return A non-null String, possibly empty. | |
| 99 | */ | |
| 100 | private String getInitScript() { | |
| 101 | return getOptions().get( PERSIST_R_STARTUP, "" ); | |
| 102 | 112 | } |
| 103 | 113 | |
| ... | ||
| 116 | 126 | final int prefixLength = PREFIX.length(); |
| 117 | 127 | |
| 118 | // Pre-allocate the same amount of space. A calculation is longer to write | |
| 119 | // than its computed value inserted into the text. | |
| 120 | final StringBuilder sb = new StringBuilder( length ); | |
| 128 | // The * 2 is a wild guess at the ratio of R statements to the length | |
| 129 | // of text produced by those statements. | |
| 130 | final StringBuilder sb = new StringBuilder( length * 2 ); | |
| 121 | 131 | |
| 122 | 132 | int prevIndex = 0; |
| ... | ||
| 133 | 143 | currIndex = text.indexOf( SUFFIX, min( currIndex + 1, length ) ); |
| 134 | 144 | |
| 135 | // Only evalutate inline R statements that have end delimiters. | |
| 145 | // Only evaluate inline R statements that have end delimiters. | |
| 136 | 146 | if( currIndex > 1 ) { |
| 137 | 147 | // Extract the inline R statement to be evaluated. |
| 138 | 148 | final String r = text.substring( prevIndex, currIndex ); |
| 139 | 149 | |
| 140 | 150 | // Pass the R statement into the R engine for evaluation. |
| 141 | 151 | try { |
| 142 | final Object result = eval( r ); | |
| 152 | final Object result = evalText( r ); | |
| 143 | 153 | |
| 144 | 154 | // Append the string representation of the result into the text. |
| ... | ||
| 168 | 178 | |
| 169 | 179 | /** |
| 170 | * Evaluate an R expression and return the resulting object. | |
| 180 | * Look up an R expression from the cache then return the resulting object. | |
| 181 | * If the R expression hasn't been cached, it'll first be evalulated. | |
| 171 | 182 | * |
| 172 | 183 | * @param r The expression to evaluate. |
| 173 | 184 | * @return The object resulting from the evaluation. |
| 174 | 185 | */ |
| 175 | private Object eval( final String r ) throws ScriptException, EvalException { | |
| 176 | return getScriptEngine().eval( r ); | |
| 177 | } | |
| 178 | ||
| 179 | private synchronized ScriptEngine getScriptEngine() { | |
| 180 | return ENGINE; | |
| 181 | } | |
| 182 | ||
| 183 | private Notifier getNotifier() { | |
| 184 | return NOTIFIER; | |
| 186 | private Object evalText( final String r ) { | |
| 187 | return mEvalCache.computeIfAbsent( r, v -> eval( r ) ); | |
| 185 | 188 | } |
| 186 | 189 | |
| 187 | private Options getOptions() { | |
| 188 | return OPTIONS; | |
| 190 | /** | |
| 191 | * Evaluate an R expression and return the resulting object. | |
| 192 | * | |
| 193 | * @param r The expression to evaluate. | |
| 194 | * @return The object resulting from the evaluation. | |
| 195 | */ | |
| 196 | private Object eval( final String r ) { | |
| 197 | try { | |
| 198 | return getScriptEngine().eval( r ); | |
| 199 | } catch( final ScriptException e ) { | |
| 200 | getNotifier().notify( e ); | |
| 201 | return ""; | |
| 202 | } | |
| 189 | 203 | } |
| 190 | 204 | |
| 191 | 205 | /** |
| 192 | 206 | * This will return the given path if not null, otherwise it will return |
| 193 | 207 | * the path to the user's directory. |
| 194 | 208 | * |
| 195 | 209 | * @return A non-null path. |
| 196 | 210 | */ |
| 197 | 211 | private Path getWorkingDirectory() { |
| 198 | return Path.of( getPreference( PERSIST_R_DIRECTORY, USER_DIRECTORY ) ); | |
| 212 | return getUserPreferences().getRDirectory().toPath(); | |
| 199 | 213 | } |
| 200 | 214 | |
| 201 | 215 | /** |
| 202 | * Returns the user-defined preference value for the given key. | |
| 216 | * Loads the R init script from the application's persisted preferences. | |
| 203 | 217 | * |
| 204 | * @param key The key to find in the user's preferences. | |
| 205 | * @param defaultValue The default value to return if no preference is set. | |
| 206 | * @return The value for the preference, or {@code defaultValue} if not found. | |
| 218 | * @return A non-null String, possibly empty. | |
| 207 | 219 | */ |
| 208 | @SuppressWarnings("SameParameterValue") | |
| 209 | private String getPreference( final String key, final String defaultValue ) { | |
| 210 | return OPTIONS.get( key, defaultValue ); | |
| 220 | private String getBootstrapScript() { | |
| 221 | return getUserPreferences().getRScript(); | |
| 222 | } | |
| 223 | ||
| 224 | private UserPreferences getUserPreferences() { | |
| 225 | return getOptions().getUserPreferences(); | |
| 226 | } | |
| 227 | ||
| 228 | private ScriptEngine getScriptEngine() { | |
| 229 | return ENGINE; | |
| 230 | } | |
| 231 | ||
| 232 | private Notifier getNotifier() { | |
| 233 | return NOTIFIER; | |
| 234 | } | |
| 235 | ||
| 236 | private Options getOptions() { | |
| 237 | return OPTIONS; | |
| 211 | 238 | } |
| 212 | 239 | } |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | import static java.lang.Character.isLetter; | |
| 31 | import static java.lang.Math.min; | |
| 32 | ||
| 33 | import javafx.beans.value.ObservableValue; | |
| 34 | ||
| 35 | /** | |
| 36 | * Responsible for inserting a caret position token into a markdown document. | |
| 37 | * | |
| 38 | * @author White Magic Software, Ltd. | |
| 39 | */ | |
| 40 | public class MarkdownCaretInsertionProcessor extends CaretInsertionProcessor { | |
| 41 | ||
| 42 | /** | |
| 43 | * Constructs a processor capable of inserting a caret marker into Markdown. | |
| 44 | * | |
| 45 | * @param processor The next processor in the chain. | |
| 46 | * @param position The caret's current position in the text. | |
| 47 | */ | |
| 48 | public MarkdownCaretInsertionProcessor( | |
| 49 | final Processor<String> processor, | |
| 50 | final ObservableValue<Integer> position ) { | |
| 51 | super( processor, position ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Changes the text to insert a "caret" at the caret position. This will | |
| 56 | * insert the unique key of Constants.MD_CARET_POSITION into the document. | |
| 57 | * | |
| 58 | * @param t The text document to process. | |
| 59 | * @return The text with the caret position token inserted at the caret | |
| 60 | * position. | |
| 61 | */ | |
| 62 | @Override | |
| 63 | public String processLink( final String t ) { | |
| 64 | final int length = t.length(); | |
| 65 | int offset = min( getCaretPosition(), length ); | |
| 66 | ||
| 67 | // TODO: Ensure that the caret position is outside of an element, | |
| 68 | // so that a caret inserted in the image doesn't corrupt it. Such as: | |
| 69 | // | |
| 70 | //  | |
| 71 | // | |
| 72 | // 1. Scan back to the previous EOL, which will be the MD AST start point. | |
| 73 | // 2. Scan forward until EOF or EOL, which will be the MD AST ending point. | |
| 74 | // 3. Convert the text between start and end into MD AST. | |
| 75 | // 4. Find the nearest text node to the caret. | |
| 76 | // 5. Insert the CARET_POSITION_MD value in the text at that offsset. | |
| 77 | // Insert the caret at the closest non-Markdown delimiter (i.e., the | |
| 78 | // closest character from the caret position forward). | |
| 79 | while( offset < length && !isLetter( t.charAt( offset ) ) ) { | |
| 80 | offset++; | |
| 81 | } | |
| 82 | ||
| 83 | return inject( t, offset ); | |
| 84 | } | |
| 85 | } | |
| 86 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension; | |
| 31 | import com.vladsch.flexmark.ext.superscript.SuperscriptExtension; | |
| 32 | import com.vladsch.flexmark.ext.tables.TablesExtension; | |
| 33 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 34 | import com.vladsch.flexmark.parser.Parser; | |
| 35 | import com.vladsch.flexmark.util.ast.Node; | |
| 36 | import com.vladsch.flexmark.util.misc.Extension; | |
| 37 | ||
| 38 | import java.util.ArrayList; | |
| 39 | import java.util.Collection; | |
| 40 | ||
| 41 | /** | |
| 42 | * Responsible for parsing a Markdown document and rendering it as HTML. | |
| 43 | * | |
| 44 | * @author White Magic Software, Ltd. | |
| 45 | */ | |
| 46 | public class MarkdownProcessor extends AbstractProcessor<String> { | |
| 47 | ||
| 48 | private final static HtmlRenderer RENDERER; | |
| 49 | private final static Parser PARSER; | |
| 50 | ||
| 51 | static { | |
| 52 | final Collection<Extension> extensions = new ArrayList<>(); | |
| 53 | extensions.add( TablesExtension.create() ); | |
| 54 | extensions.add( SuperscriptExtension.create() ); | |
| 55 | extensions.add( StrikethroughSubscriptExtension.create() ); | |
| 56 | ||
| 57 | RENDERER = HtmlRenderer.builder().extensions( extensions ).build(); | |
| 58 | PARSER = Parser.builder().extensions( extensions ).build(); | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Constructs a new Markdown processor that can create HTML documents. | |
| 63 | * | |
| 64 | * @param successor Usually the HTML Preview Processor. | |
| 65 | */ | |
| 66 | public MarkdownProcessor( final Processor<String> successor ) { | |
| 67 | super( successor ); | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Converts the given Markdown string into HTML, without the doctype, html, | |
| 72 | * head, and body tags. | |
| 73 | * | |
| 74 | * @param markdown The string to convert from Markdown to HTML. | |
| 75 | * @return The HTML representation of the Markdown document. | |
| 76 | */ | |
| 77 | @Override | |
| 78 | public String processLink( final String markdown ) { | |
| 79 | return toHtml( markdown ); | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Returns the AST in the form of a node for the given markdown document. This | |
| 84 | * can be used, for example, to determine if a hyperlink exists inside of a | |
| 85 | * paragraph. | |
| 86 | * | |
| 87 | * @param markdown The markdown to convert into an AST. | |
| 88 | * @return The markdown AST for the given text (usually a paragraph). | |
| 89 | */ | |
| 90 | public Node toNode( final String markdown ) { | |
| 91 | return parse( markdown ); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Helper method to create an AST given some markdown. | |
| 96 | * | |
| 97 | * @param markdown The markdown to parse. | |
| 98 | * @return The root node of the markdown tree. | |
| 99 | */ | |
| 100 | private Node parse( final String markdown ) { | |
| 101 | return getParser().parse( markdown ); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Converts a string of markdown into HTML. | |
| 106 | * | |
| 107 | * @param markdown The markdown text to convert to HTML, must not be null. | |
| 108 | * @return The markdown rendered as an HTML document. | |
| 109 | */ | |
| 110 | private String toHtml( final String markdown ) { | |
| 111 | return getRenderer().render( parse( markdown ) ); | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Creates the Markdown document processor. | |
| 116 | * | |
| 117 | * @return A Parser that can build an abstract syntax tree. | |
| 118 | */ | |
| 119 | private Parser getParser() { | |
| 120 | return PARSER; | |
| 121 | } | |
| 122 | ||
| 123 | private HtmlRenderer getRenderer() { | |
| 124 | return RENDERER; | |
| 125 | } | |
| 126 | } | |
| 127 | 1 |
| 31 | 31 | import com.scrivenvar.FileEditorTab; |
| 32 | 32 | import com.scrivenvar.preview.HTMLPreviewPane; |
| 33 | import javafx.beans.value.ObservableValue; | |
| 33 | import com.scrivenvar.processors.markdown.MarkdownProcessor; | |
| 34 | 34 | |
| 35 | 35 | import java.nio.file.Path; |
| ... | ||
| 46 | 46 | private final HTMLPreviewPane mPreviewPane; |
| 47 | 47 | private final Map<String, String> mResolvedMap; |
| 48 | private final Processor<String> mCommonProcessor; | |
| 48 | private final Processor<String> mMarkdownProcessor; | |
| 49 | 49 | |
| 50 | 50 | /** |
| ... | ||
| 60 | 60 | mPreviewPane = previewPane; |
| 61 | 61 | mResolvedMap = resolvedMap; |
| 62 | mCommonProcessor = createCommonProcessor(); | |
| 62 | mMarkdownProcessor = createMarkdownProcessor(); | |
| 63 | 63 | } |
| 64 | 64 | |
| ... | ||
| 76 | 76 | switch( lookup( path ) ) { |
| 77 | 77 | case RMARKDOWN: |
| 78 | processor = createRProcessor( tab ); | |
| 78 | processor = createRProcessor(); | |
| 79 | 79 | break; |
| 80 | 80 | |
| 81 | 81 | case SOURCE: |
| 82 | processor = createMarkdownProcessor( tab ); | |
| 82 | processor = createMarkdownDefinitionProcessor(); | |
| 83 | 83 | break; |
| 84 | 84 | |
| ... | ||
| 97 | 97 | |
| 98 | 98 | return processor; |
| 99 | } | |
| 100 | ||
| 101 | private Processor<String> createHTMLPreviewProcessor() { | |
| 102 | return new HTMLPreviewProcessor( getPreviewPane() ); | |
| 99 | 103 | } |
| 100 | 104 | |
| 101 | 105 | /** |
| 102 | 106 | * Creates and links the processors at the end of the processing chain. |
| 103 | 107 | * |
| 104 | 108 | * @return A markdown, caret replacement, and preview pane processor chain. |
| 105 | 109 | */ |
| 106 | private Processor<String> createCommonProcessor() { | |
| 107 | final var hpp = new HTMLPreviewProcessor( getPreviewPane() ); | |
| 108 | final var mcrp = new CaretReplacementProcessor( hpp ); | |
| 109 | ||
| 110 | return new MarkdownProcessor( mcrp ); | |
| 110 | private Processor<String> createMarkdownProcessor() { | |
| 111 | final var hpp = createHTMLPreviewProcessor(); | |
| 112 | return new MarkdownProcessor( hpp, getPreviewPane().getPath() ); | |
| 111 | 113 | } |
| 112 | 114 | |
| 113 | 115 | protected Processor<String> createIdentityProcessor() { |
| 114 | final var hpp = new HTMLPreviewProcessor( getPreviewPane() ); | |
| 115 | ||
| 116 | final var hpp = createHTMLPreviewProcessor(); | |
| 116 | 117 | return new IdentityProcessor( hpp ); |
| 117 | 118 | } |
| 118 | 119 | |
| 119 | 120 | protected Processor<String> createDefinitionProcessor( |
| 120 | 121 | final Processor<String> p ) { |
| 121 | 122 | return new DefinitionProcessor( p, getResolvedMap() ); |
| 122 | 123 | } |
| 123 | 124 | |
| 124 | protected Processor<String> createMarkdownProcessor( | |
| 125 | final FileEditorTab tab ) { | |
| 126 | final var caret = tab.caretPositionProperty(); | |
| 125 | protected Processor<String> createMarkdownDefinitionProcessor() { | |
| 127 | 126 | final var tpc = getCommonProcessor(); |
| 128 | final var cip = createMarkdownInsertionProcessor( tpc, caret ); | |
| 129 | ||
| 130 | return createDefinitionProcessor( cip ); | |
| 127 | return createDefinitionProcessor( tpc ); | |
| 131 | 128 | } |
| 132 | 129 | |
| 133 | 130 | protected Processor<String> createXMLProcessor( final FileEditorTab tab ) { |
| 134 | final var caret = tab.caretPositionProperty(); | |
| 135 | 131 | final var tpc = getCommonProcessor(); |
| 136 | 132 | final var xmlp = new XMLProcessor( tpc, tab.getPath() ); |
| 137 | final var dp = createDefinitionProcessor( xmlp ); | |
| 138 | ||
| 139 | return createXMLInsertionProcessor( dp, caret ); | |
| 133 | return createDefinitionProcessor( xmlp ); | |
| 140 | 134 | } |
| 141 | 135 | |
| 142 | protected Processor<String> createRProcessor( final FileEditorTab tab ) { | |
| 143 | final var caret = tab.caretPositionProperty(); | |
| 136 | protected Processor<String> createRProcessor() { | |
| 144 | 137 | final var tpc = getCommonProcessor(); |
| 145 | 138 | final var rp = new InlineRProcessor( tpc, getResolvedMap() ); |
| 146 | final var rvp = new RVariableProcessor( rp, getResolvedMap() ); | |
| 147 | ||
| 148 | return createRInsertionProcessor( rvp, caret ); | |
| 139 | return new RVariableProcessor( rp, getResolvedMap() ); | |
| 149 | 140 | } |
| 150 | 141 | |
| 151 | 142 | protected Processor<String> createRXMLProcessor( final FileEditorTab tab ) { |
| 152 | final var caret = tab.caretPositionProperty(); | |
| 153 | 143 | final var tpc = getCommonProcessor(); |
| 154 | 144 | final var xmlp = new XMLProcessor( tpc, tab.getPath() ); |
| 155 | 145 | final var rp = new InlineRProcessor( xmlp, getResolvedMap() ); |
| 156 | final var rvp = new RVariableProcessor( rp, getResolvedMap() ); | |
| 157 | ||
| 158 | return createXMLInsertionProcessor( rvp, caret ); | |
| 159 | } | |
| 160 | ||
| 161 | private Processor<String> createMarkdownInsertionProcessor( | |
| 162 | final Processor<String> tpc, final ObservableValue<Integer> caret ) { | |
| 163 | return new MarkdownCaretInsertionProcessor( tpc, caret ); | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Create an insertion processor that is aware of R statements and will insert | |
| 168 | * a caret outside of any statement the caret falls within. | |
| 169 | * | |
| 170 | * @param processor Another link in the processor chain. | |
| 171 | * @param caret The caret insertion point. | |
| 172 | * @return A processor that can insert a caret token without disturbing any R | |
| 173 | * code. | |
| 174 | */ | |
| 175 | private Processor<String> createRInsertionProcessor( | |
| 176 | final Processor<String> processor, | |
| 177 | final ObservableValue<Integer> caret ) { | |
| 178 | return new RMarkdownCaretInsertionProcessor( processor, caret ); | |
| 179 | } | |
| 180 | ||
| 181 | private Processor<String> createXMLInsertionProcessor( | |
| 182 | final Processor<String> tpc, final ObservableValue<Integer> caret ) { | |
| 183 | return new XMLCaretInsertionProcessor( tpc, caret ); | |
| 146 | return new RVariableProcessor( rp, getResolvedMap() ); | |
| 184 | 147 | } |
| 185 | 148 | |
| ... | ||
| 204 | 167 | */ |
| 205 | 168 | private Processor<String> getCommonProcessor() { |
| 206 | return mCommonProcessor; | |
| 169 | return mMarkdownProcessor; | |
| 207 | 170 | } |
| 208 | 171 | } |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | import static com.scrivenvar.decorators.RVariableDecorator.PREFIX; | |
| 31 | import static com.scrivenvar.decorators.RVariableDecorator.SUFFIX; | |
| 32 | import static java.lang.Integer.max; | |
| 33 | ||
| 34 | import javafx.beans.value.ObservableValue; | |
| 35 | ||
| 36 | /** | |
| 37 | * Responsible for inserting a caret position token into an R document. | |
| 38 | * | |
| 39 | * @author White Magic Software, Ltd. | |
| 40 | */ | |
| 41 | public class RMarkdownCaretInsertionProcessor | |
| 42 | extends MarkdownCaretInsertionProcessor { | |
| 43 | ||
| 44 | /** | |
| 45 | * Constructs a processor capable of inserting a caret marker into Markdown. | |
| 46 | * | |
| 47 | * @param processor The next processor in the chain. | |
| 48 | * @param position The caret's current position in the text. | |
| 49 | */ | |
| 50 | public RMarkdownCaretInsertionProcessor( | |
| 51 | final Processor<String> processor, | |
| 52 | final ObservableValue<Integer> position ) { | |
| 53 | super( processor, position ); | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Changes the text to insert a "caret" at the caret position. This will | |
| 58 | * insert the unique key of Constants.MD_CARET_POSITION into the document. | |
| 59 | * | |
| 60 | * @param text The text document to process. | |
| 61 | * @return The text with the caret position token inserted at the caret | |
| 62 | * position. | |
| 63 | */ | |
| 64 | @Override | |
| 65 | public String processLink( final String text ) { | |
| 66 | int offset = getCaretPosition(); | |
| 67 | ||
| 68 | // Search for inline R code from the start of the caret's paragraph. | |
| 69 | // This should be much faster than scanning text from the beginning. | |
| 70 | int index = text.lastIndexOf( NEWLINE, offset ); | |
| 71 | ||
| 72 | if( index == INDEX_NOT_FOUND ) { | |
| 73 | index = 0; | |
| 74 | } | |
| 75 | ||
| 76 | // Scan for an inline R statement, either from the nearest paragraph or | |
| 77 | // the beginning of the file, whichever was found first. | |
| 78 | index = text.indexOf( PREFIX, index ); | |
| 79 | ||
| 80 | // If there was no R prefix then insert at the caret's initial offset... | |
| 81 | if( index != INDEX_NOT_FOUND ) { | |
| 82 | // Otherwise, retain the starting index of the first R statement in the | |
| 83 | // paragraph. | |
| 84 | int rPrefix = index + 1; | |
| 85 | ||
| 86 | // Scan for inline R prefixes until the text is exhausted or indexed | |
| 87 | // beyond the caret position. | |
| 88 | while( index != INDEX_NOT_FOUND && index < offset ) { | |
| 89 | // Set rPrefix to the index that might precede the caret. The + 1 is | |
| 90 | // to skip passed the leading backtick in the prefix (`r#). | |
| 91 | rPrefix = index + 1; | |
| 92 | ||
| 93 | // If there are no more R prefixes, exit the loop and look for a | |
| 94 | // suffix starting from the rPrefix position. | |
| 95 | index = text.indexOf( PREFIX, rPrefix ); | |
| 96 | } | |
| 97 | ||
| 98 | // Scan from the character after the R prefix up to any R suffix. | |
| 99 | final int rSuffix = max( text.indexOf( SUFFIX, rPrefix ), rPrefix ); | |
| 100 | ||
| 101 | // If the caret falls between the rPrefix and rSuffix, then change the | |
| 102 | // insertion point. | |
| 103 | final boolean between = isBetween( offset, rPrefix, rSuffix ); | |
| 104 | ||
| 105 | // Insert the caret marker at the start of the R statement. | |
| 106 | if( between ) { | |
| 107 | offset = rPrefix - 1; | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | return inject( text, offset ); | |
| 112 | } | |
| 113 | } | |
| 114 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors; | |
| 29 | ||
| 30 | import com.ximpleware.VTDException; | |
| 31 | import com.ximpleware.VTDGen; | |
| 32 | import static com.ximpleware.VTDGen.TOKEN_CHARACTER_DATA; | |
| 33 | import com.ximpleware.VTDNav; | |
| 34 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 35 | import java.text.ParseException; | |
| 36 | import javafx.beans.value.ObservableValue; | |
| 37 | ||
| 38 | /** | |
| 39 | * Inserts a caret position indicator into the document. | |
| 40 | * | |
| 41 | * @author White Magic Software, Ltd. | |
| 42 | */ | |
| 43 | public class XMLCaretInsertionProcessor extends CaretInsertionProcessor { | |
| 44 | ||
| 45 | private final static VTDGen PARSER = new VTDGen(); | |
| 46 | ||
| 47 | /** | |
| 48 | * Constructs a processor capable of inserting a caret marker into XML. | |
| 49 | * | |
| 50 | * @param processor The next processor in the chain. | |
| 51 | * @param position The caret's current position in the text, cannot be null. | |
| 52 | */ | |
| 53 | public XMLCaretInsertionProcessor( | |
| 54 | final Processor<String> processor, | |
| 55 | final ObservableValue<Integer> position ) { | |
| 56 | super( processor, position ); | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * Inserts a caret at a valid position within the XML document. | |
| 61 | * | |
| 62 | * @param text The string into which caret position marker text is inserted. | |
| 63 | * | |
| 64 | * @return The text with a caret position marker included, or the original | |
| 65 | * text if no insertion point could be found. | |
| 66 | */ | |
| 67 | @Override | |
| 68 | public String processLink( final String text ) { | |
| 69 | final int caret = getCaretPosition(); | |
| 70 | int insertOffset = -1; | |
| 71 | ||
| 72 | if( text.length() > 0 ) { | |
| 73 | try { | |
| 74 | final VTDNav vn = getNavigator( text ); | |
| 75 | final int tokens = vn.getTokenCount(); | |
| 76 | ||
| 77 | int currTokenIndex = 0; | |
| 78 | int prevTokenIndex = currTokenIndex; | |
| 79 | int currOffset = 0; | |
| 80 | ||
| 81 | // To find the insertion spot even faster, the algorithm could | |
| 82 | // use a binary search or interpolation search algorithm. This | |
| 83 | // would reduce the worst-case iterations to O(log n) from O(n). | |
| 84 | while( currTokenIndex < tokens ) { | |
| 85 | if( vn.getTokenType( currTokenIndex ) == TOKEN_CHARACTER_DATA ) { | |
| 86 | final int prevOffset = currOffset; | |
| 87 | currOffset = vn.getTokenOffset( currTokenIndex ); | |
| 88 | ||
| 89 | if( currOffset > caret ) { | |
| 90 | final int prevLength = vn.getTokenLength( prevTokenIndex ); | |
| 91 | ||
| 92 | // If the caret falls within the limits of the previous token, | |
| 93 | // theninsert the caret position marker at the caret offset. | |
| 94 | if( isBetween( caret, prevOffset, prevOffset + prevLength ) ) { | |
| 95 | insertOffset = caret; | |
| 96 | } else { | |
| 97 | // The caret position is outside the previous token's text | |
| 98 | // boundaries, but not inside the current text token. The | |
| 99 | // caret should be positioned into the closer text token. | |
| 100 | // For now, the cursor is positioned at the start of the | |
| 101 | // current text token. | |
| 102 | insertOffset = currOffset; | |
| 103 | } | |
| 104 | ||
| 105 | break; | |
| 106 | } | |
| 107 | ||
| 108 | prevTokenIndex = currTokenIndex; | |
| 109 | } | |
| 110 | ||
| 111 | currTokenIndex++; | |
| 112 | } | |
| 113 | ||
| 114 | } catch( final Exception ex ) { | |
| 115 | throw new RuntimeException( | |
| 116 | new ParseException( ex.getMessage(), caret ) | |
| 117 | ); | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | return inject( text, insertOffset ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Parses the given XML document and returns a high-performance navigator | |
| 126 | * instance for scanning through the XML elements. | |
| 127 | * | |
| 128 | * @param xml The XML document to parse. | |
| 129 | * | |
| 130 | * @return A document navigator instance. | |
| 131 | */ | |
| 132 | private VTDNav getNavigator( final String xml ) throws VTDException { | |
| 133 | final VTDGen vg = getParser(); | |
| 134 | ||
| 135 | // XML recommends UTF-8 encoding. | |
| 136 | // See: http://stackoverflow.com/a/36696214/59087 | |
| 137 | // | |
| 138 | // The encoding should be derived, not assumed. | |
| 139 | vg.setDoc( xml.getBytes( UTF_8 ) ); | |
| 140 | vg.parse( true ); | |
| 141 | return vg.getNav(); | |
| 142 | } | |
| 143 | ||
| 144 | private synchronized VTDGen getParser() { | |
| 145 | return PARSER; | |
| 146 | } | |
| 147 | } | |
| 148 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors.markdown; | |
| 29 | ||
| 30 | import com.scrivenvar.Services; | |
| 31 | import com.scrivenvar.preferences.UserPreferences; | |
| 32 | import com.scrivenvar.service.Options; | |
| 33 | import com.scrivenvar.service.events.Notifier; | |
| 34 | import com.scrivenvar.util.ProtocolResolver; | |
| 35 | import com.vladsch.flexmark.ast.Image; | |
| 36 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 37 | import com.vladsch.flexmark.html.IndependentLinkResolverFactory; | |
| 38 | import com.vladsch.flexmark.html.LinkResolver; | |
| 39 | import com.vladsch.flexmark.html.renderer.LinkResolverBasicContext; | |
| 40 | import com.vladsch.flexmark.html.renderer.LinkStatus; | |
| 41 | import com.vladsch.flexmark.html.renderer.ResolvedLink; | |
| 42 | import com.vladsch.flexmark.util.ast.Node; | |
| 43 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 44 | import org.jetbrains.annotations.NotNull; | |
| 45 | import org.renjin.repackaged.guava.base.Splitter; | |
| 46 | ||
| 47 | import java.io.File; | |
| 48 | import java.nio.file.Path; | |
| 49 | ||
| 50 | import static java.lang.String.format; | |
| 51 | ||
| 52 | /** | |
| 53 | * Responsible for ensuring that images can be rendered relative to a path. | |
| 54 | * This allows images to be located virtually anywhere. | |
| 55 | * | |
| 56 | * @author White Magic Software, Ltd. | |
| 57 | */ | |
| 58 | public class ImageLinkExtension implements HtmlRenderer.HtmlRendererExtension { | |
| 59 | private final static Options sOptions = Services.load( Options.class ); | |
| 60 | private final static Notifier sNotifier = Services.load( Notifier.class ); | |
| 61 | ||
| 62 | /** | |
| 63 | * Creates an extension capable of using a relative path to embed images. | |
| 64 | * | |
| 65 | * @param path The {@link Path} to the file being edited; the parent path | |
| 66 | * is the starting location of the relative image directory. | |
| 67 | * @return The new {@link ImageLinkExtension}, never {@code null}. | |
| 68 | */ | |
| 69 | public static ImageLinkExtension create( final Path path ) { | |
| 70 | return new ImageLinkExtension( path ); | |
| 71 | } | |
| 72 | ||
| 73 | private class Factory extends IndependentLinkResolverFactory { | |
| 74 | @Override | |
| 75 | public @NotNull LinkResolver apply( | |
| 76 | @NotNull final LinkResolverBasicContext context ) { | |
| 77 | return new ImageLinkResolver(); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | private class ImageLinkResolver implements LinkResolver { | |
| 82 | private final UserPreferences mUserPref = getUserPreferences(); | |
| 83 | private final String mImagePrefix = | |
| 84 | mUserPref.getImagesDirectory().toString(); | |
| 85 | private final String mImageSuffixes = mUserPref.getImagesOrder(); | |
| 86 | ||
| 87 | public ImageLinkResolver() { | |
| 88 | } | |
| 89 | ||
| 90 | // you can also set/clear/modify attributes through | |
| 91 | // ResolvedLink.getAttributes() and | |
| 92 | // ResolvedLink.getNonNullAttributes() | |
| 93 | @NotNull | |
| 94 | @Override | |
| 95 | public ResolvedLink resolveLink( | |
| 96 | @NotNull final Node node, | |
| 97 | @NotNull final LinkResolverBasicContext context, | |
| 98 | @NotNull final ResolvedLink link ) { | |
| 99 | return node instanceof Image ? resolve( link ) : link; | |
| 100 | } | |
| 101 | ||
| 102 | @NotNull | |
| 103 | private ResolvedLink resolve( @NotNull final ResolvedLink link ) { | |
| 104 | String url = link.getUrl(); | |
| 105 | ||
| 106 | try { | |
| 107 | final String imageFile = format( "%s/%s", getImagePrefix(), url ); | |
| 108 | final String suffixes = getImageSuffixes(); | |
| 109 | final String editDir = getEditDirectory(); | |
| 110 | ||
| 111 | for( final String ext : Splitter.on( ' ' ).split( suffixes ) ) { | |
| 112 | final String imagePath = format( | |
| 113 | "%s/%s.%s", editDir, imageFile, ext ); | |
| 114 | final File file = new File( imagePath ); | |
| 115 | ||
| 116 | if( file.exists() ) { | |
| 117 | url = file.toString(); | |
| 118 | break; | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | final String protocol = ProtocolResolver.getProtocol( url ); | |
| 123 | if( "file".equals( protocol ) ) { | |
| 124 | url = "file://" + url; | |
| 125 | } | |
| 126 | ||
| 127 | return link.withStatus( LinkStatus.VALID ).withUrl( url ); | |
| 128 | } catch( final Exception e ) { | |
| 129 | getNotifier().notify( e ); | |
| 130 | } | |
| 131 | ||
| 132 | return link; | |
| 133 | } | |
| 134 | ||
| 135 | private String getImagePrefix() { | |
| 136 | return mImagePrefix; | |
| 137 | } | |
| 138 | ||
| 139 | private String getImageSuffixes() { | |
| 140 | return mImageSuffixes; | |
| 141 | } | |
| 142 | ||
| 143 | private String getEditDirectory() { | |
| 144 | return mPath.getParent().toString(); | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | private final Path mPath; | |
| 149 | ||
| 150 | private ImageLinkExtension( final Path path ) { | |
| 151 | mPath = path; | |
| 152 | } | |
| 153 | ||
| 154 | @Override | |
| 155 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 156 | } | |
| 157 | ||
| 158 | @Override | |
| 159 | public void extend( | |
| 160 | final HtmlRenderer.Builder rendererBuilder, | |
| 161 | @NotNull final String rendererType ) { | |
| 162 | rendererBuilder.linkResolverFactory( new Factory() ); | |
| 163 | } | |
| 164 | ||
| 165 | private UserPreferences getUserPreferences() { | |
| 166 | return getOptions().getUserPreferences(); | |
| 167 | } | |
| 168 | ||
| 169 | private Options getOptions() { | |
| 170 | return sOptions; | |
| 171 | } | |
| 172 | ||
| 173 | private Notifier getNotifier() { | |
| 174 | return sNotifier; | |
| 175 | } | |
| 176 | } | |
| 1 | 177 |
| 1 | /* | |
| 2 | * Copyright 2016 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.processors.markdown; | |
| 29 | ||
| 30 | import com.scrivenvar.processors.AbstractProcessor; | |
| 31 | import com.scrivenvar.processors.Processor; | |
| 32 | import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughSubscriptExtension; | |
| 33 | import com.vladsch.flexmark.ext.superscript.SuperscriptExtension; | |
| 34 | import com.vladsch.flexmark.ext.tables.TablesExtension; | |
| 35 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 36 | import com.vladsch.flexmark.parser.Parser; | |
| 37 | import com.vladsch.flexmark.util.ast.IParse; | |
| 38 | import com.vladsch.flexmark.util.ast.Node; | |
| 39 | import com.vladsch.flexmark.util.misc.Extension; | |
| 40 | ||
| 41 | import java.nio.file.Path; | |
| 42 | import java.util.ArrayList; | |
| 43 | import java.util.Collection; | |
| 44 | ||
| 45 | import static com.scrivenvar.Constants.USER_DIRECTORY; | |
| 46 | ||
| 47 | /** | |
| 48 | * Responsible for parsing a Markdown document and rendering it as HTML. | |
| 49 | * | |
| 50 | * @author White Magic Software, Ltd. | |
| 51 | */ | |
| 52 | public class MarkdownProcessor extends AbstractProcessor<String> { | |
| 53 | ||
| 54 | private final HtmlRenderer mRenderer; | |
| 55 | private final IParse mParser; | |
| 56 | ||
| 57 | public MarkdownProcessor( | |
| 58 | final Processor<String> successor ) { | |
| 59 | this( successor, Path.of( USER_DIRECTORY ) ); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Constructs a new Markdown processor that can create HTML documents. | |
| 64 | * | |
| 65 | * @param successor Usually the HTML Preview Processor. | |
| 66 | */ | |
| 67 | public MarkdownProcessor( | |
| 68 | final Processor<String> successor, final Path path ) { | |
| 69 | super( successor ); | |
| 70 | ||
| 71 | final Collection<Extension> extensions = new ArrayList<>(); | |
| 72 | extensions.add( TablesExtension.create() ); | |
| 73 | extensions.add( SuperscriptExtension.create() ); | |
| 74 | extensions.add( StrikethroughSubscriptExtension.create() ); | |
| 75 | extensions.add( ImageLinkExtension.create( path ) ); | |
| 76 | ||
| 77 | mRenderer = HtmlRenderer.builder().extensions( extensions ).build(); | |
| 78 | mParser = Parser.builder().extensions( extensions ).build(); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Converts the given Markdown string into HTML, without the doctype, html, | |
| 83 | * head, and body tags. | |
| 84 | * | |
| 85 | * @param markdown The string to convert from Markdown to HTML. | |
| 86 | * @return The HTML representation of the Markdown document. | |
| 87 | */ | |
| 88 | @Override | |
| 89 | public String processLink( final String markdown ) { | |
| 90 | return toHtml( markdown ); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Returns the AST in the form of a node for the given markdown document. This | |
| 95 | * can be used, for example, to determine if a hyperlink exists inside of a | |
| 96 | * paragraph. | |
| 97 | * | |
| 98 | * @param markdown The markdown to convert into an AST. | |
| 99 | * @return The markdown AST for the given text (usually a paragraph). | |
| 100 | */ | |
| 101 | public Node toNode( final String markdown ) { | |
| 102 | return parse( markdown ); | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Helper method to create an AST given some markdown. | |
| 107 | * | |
| 108 | * @param markdown The markdown to parse. | |
| 109 | * @return The root node of the markdown tree. | |
| 110 | */ | |
| 111 | private Node parse( final String markdown ) { | |
| 112 | return getParser().parse( markdown ); | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * Converts a string of markdown into HTML. | |
| 117 | * | |
| 118 | * @param markdown The markdown text to convert to HTML, must not be null. | |
| 119 | * @return The markdown rendered as an HTML document. | |
| 120 | */ | |
| 121 | private String toHtml( final String markdown ) { | |
| 122 | return getRenderer().render( parse( markdown ) ); | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Creates the Markdown document processor. | |
| 127 | * | |
| 128 | * @return A Parser that can build an abstract syntax tree. | |
| 129 | */ | |
| 130 | private IParse getParser() { | |
| 131 | return mParser; | |
| 132 | } | |
| 133 | ||
| 134 | private HtmlRenderer getRenderer() { | |
| 135 | return mRenderer; | |
| 136 | } | |
| 137 | } | |
| 1 | 138 |
| 28 | 28 | package com.scrivenvar.service; |
| 29 | 29 | |
| 30 | import com.scrivenvar.preferences.UserPreferences; | |
| 31 | ||
| 30 | 32 | import java.util.prefs.BackingStoreException; |
| 31 | 33 | import java.util.prefs.Preferences; |
| 32 | 34 | |
| 33 | 35 | /** |
| 34 | 36 | * Responsible for persisting options. |
| 35 | 37 | * |
| 36 | 38 | * @author White Magic Software, Ltd. |
| 37 | 39 | */ |
| 38 | 40 | public interface Options extends Service { |
| 41 | ||
| 42 | /** | |
| 43 | * Returns a reference to the persistent settings that may be configured | |
| 44 | * through the UI. | |
| 45 | * | |
| 46 | * @return A valid {@link UserPreferences} instance, never {@code null}. | |
| 47 | */ | |
| 48 | UserPreferences getUserPreferences(); | |
| 39 | 49 | |
| 50 | /** | |
| 51 | * Returns the {@link Preferences} that persist settings that cannot | |
| 52 | * be configured via the user interface. | |
| 53 | * | |
| 54 | * @return A valid {@link Preferences} instance, never {@code null}. | |
| 55 | */ | |
| 40 | 56 | Preferences getState(); |
| 41 | 57 |
| 27 | 27 | package com.scrivenvar.service.impl; |
| 28 | 28 | |
| 29 | import static com.scrivenvar.Constants.PREFS_OPTIONS; | |
| 30 | import static com.scrivenvar.Constants.PREFS_ROOT; | |
| 31 | import static com.scrivenvar.Constants.PREFS_STATE; | |
| 32 | ||
| 29 | import com.scrivenvar.preferences.UserPreferences; | |
| 33 | 30 | import com.scrivenvar.service.Options; |
| 34 | 31 | |
| 35 | 32 | import java.util.prefs.BackingStoreException; |
| 36 | 33 | import java.util.prefs.Preferences; |
| 37 | 34 | |
| 35 | import static com.scrivenvar.Constants.PREFS_ROOT; | |
| 36 | import static com.scrivenvar.Constants.PREFS_STATE; | |
| 38 | 37 | import static java.util.prefs.Preferences.userRoot; |
| 39 | 38 | |
| 40 | 39 | /** |
| 41 | 40 | * Persistent options user can change at runtime. |
| 42 | 41 | * |
| 43 | 42 | * @author Karl Tauber and White Magic Software, Ltd. |
| 44 | 43 | */ |
| 45 | 44 | public class DefaultOptions implements Options { |
| 46 | ||
| 47 | private Preferences mPreferences; | |
| 45 | private final UserPreferences mPreferences = new UserPreferences(); | |
| 48 | 46 | |
| 49 | 47 | public DefaultOptions() { |
| 50 | setPreferences( getRootPreferences().node( PREFS_OPTIONS ) ); | |
| 51 | 48 | } |
| 52 | 49 | |
| ... | ||
| 74 | 71 | public String get( final String key ) { |
| 75 | 72 | return get( key, "" ); |
| 76 | } | |
| 77 | ||
| 78 | private void setPreferences( final Preferences preferences ) { | |
| 79 | mPreferences = preferences; | |
| 80 | 73 | } |
| 81 | 74 | |
| 82 | 75 | private Preferences getRootPreferences() { |
| 83 | 76 | return userRoot().node( PREFS_ROOT ); |
| 84 | 77 | } |
| 85 | 78 | |
| 86 | 79 | @Override |
| 87 | 80 | public Preferences getState() { |
| 88 | 81 | return getRootPreferences().node( PREFS_STATE ); |
| 82 | } | |
| 83 | ||
| 84 | @Override | |
| 85 | public UserPreferences getUserPreferences() { | |
| 86 | return mPreferences; | |
| 89 | 87 | } |
| 90 | 88 | } |
| 1 | 1 | /* |
| 2 | * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com> | |
| 2 | * Copyright (c) 2015 Karl Tauber and White Magic Software, Ltd. | |
| 3 | 3 | * All rights reserved. |
| 4 | 4 | * |
| ... | ||
| 37 | 37 | * |
| 38 | 38 | * @author Karl Tauber |
| 39 | * @author White Magic Software, Ltd. | |
| 39 | 40 | */ |
| 40 | 41 | public class Action { |
| 41 | ||
| 42 | 42 | public final String text; |
| 43 | 43 | public final KeyCombination accelerator; |
| 44 | 44 | public final GlyphIcons icon; |
| 45 | 45 | public final EventHandler<ActionEvent> action; |
| 46 | 46 | public final ObservableBooleanValue disable; |
| 47 | ||
| 48 | public Action( | |
| 49 | final String text, | |
| 50 | final String accelerator, | |
| 51 | final GlyphIcons icon, | |
| 52 | final EventHandler<ActionEvent> action ) { | |
| 53 | this( text, accelerator, icon, action, null ); | |
| 54 | } | |
| 55 | 47 | |
| 56 | 48 | public Action( |
| 57 | final String text, | |
| 58 | final String accelerator, | |
| 59 | final GlyphIcons icon, | |
| 60 | final EventHandler<ActionEvent> action, | |
| 61 | final ObservableBooleanValue disable ) { | |
| 49 | final String text, | |
| 50 | final String accelerator, | |
| 51 | final GlyphIcons icon, | |
| 52 | final EventHandler<ActionEvent> action, | |
| 53 | final ObservableBooleanValue disable ) { | |
| 62 | 54 | |
| 63 | 55 | this.text = text; |
| 64 | this.accelerator = (accelerator != null) | |
| 65 | ? KeyCombination.valueOf( accelerator ) | |
| 66 | : null; | |
| 56 | this.accelerator = accelerator == null ? | |
| 57 | null : KeyCombination.valueOf( accelerator ); | |
| 67 | 58 | this.icon = icon; |
| 68 | 59 | this.action = action; |
| 1 | /* | |
| 2 | * Copyright 2020 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.scrivenvar.util; | |
| 29 | ||
| 30 | import com.scrivenvar.Messages; | |
| 31 | import de.jensd.fx.glyphs.GlyphIcons; | |
| 32 | import javafx.beans.value.ObservableBooleanValue; | |
| 33 | import javafx.event.ActionEvent; | |
| 34 | import javafx.event.EventHandler; | |
| 35 | ||
| 36 | /** | |
| 37 | * Provides a fluent interface around constructing actions so that duplication | |
| 38 | * can be avoided. | |
| 39 | */ | |
| 40 | public class ActionBuilder { | |
| 41 | private String mText; | |
| 42 | private String mAccelerator; | |
| 43 | private GlyphIcons mIcon; | |
| 44 | private EventHandler<ActionEvent> mAction; | |
| 45 | private ObservableBooleanValue mDisable; | |
| 46 | ||
| 47 | /** | |
| 48 | * Sets the action text based on a resource bundle key. | |
| 49 | * | |
| 50 | * @param key The key to look up in the {@link Messages}. | |
| 51 | * @return The corresponding value, or the key name if none found. | |
| 52 | */ | |
| 53 | public ActionBuilder setText( final String key ) { | |
| 54 | mText = Messages.get( key, key ); | |
| 55 | return this; | |
| 56 | } | |
| 57 | ||
| 58 | public ActionBuilder setAccelerator( final String accelerator ) { | |
| 59 | mAccelerator = accelerator; | |
| 60 | return this; | |
| 61 | } | |
| 62 | ||
| 63 | public ActionBuilder setIcon( final GlyphIcons icon ) { | |
| 64 | mIcon = icon; | |
| 65 | return this; | |
| 66 | } | |
| 67 | ||
| 68 | public ActionBuilder setAction( final EventHandler<ActionEvent> action ) { | |
| 69 | mAction = action; | |
| 70 | return this; | |
| 71 | } | |
| 72 | ||
| 73 | public ActionBuilder setDisable( final ObservableBooleanValue disable ) { | |
| 74 | mDisable = disable; | |
| 75 | return this; | |
| 76 | } | |
| 77 | ||
| 78 | public Action build() { | |
| 79 | return new Action( mText, mAccelerator, mIcon, mAction, mDisable ); | |
| 80 | } | |
| 81 | } | |
| 1 | 82 |
| 1 | 1 | /* |
| 2 | * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com> | |
| 2 | * Copyright (c) 2015 Karl Tauber | |
| 3 | 3 | * All rights reserved. |
| 4 | 4 | * |
| ... | ||
| 41 | 41 | * |
| 42 | 42 | * @author Karl Tauber |
| 43 | * @author White Magic Software, Ltd. | |
| 43 | 44 | */ |
| 44 | 45 | public class ActionUtils { |
| 45 | 46 | |
| 46 | 47 | public static Menu createMenu( final String text, final Action... actions ) { |
| 47 | 48 | return new Menu( text, null, createMenuItems( actions ) ); |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | public static MenuItem[] createMenuItems( Action... actions ) { | |
| 51 | MenuItem[] menuItems = new MenuItem[ actions.length ]; | |
| 51 | public static MenuItem[] createMenuItems( final Action... actions ) { | |
| 52 | final MenuItem[] menuItems = new MenuItem[ actions.length ]; | |
| 53 | ||
| 52 | 54 | for( int i = 0; i < actions.length; i++ ) { |
| 53 | 55 | menuItems[ i ] = (actions[ i ] != null) |
| 54 | ? createMenuItem( actions[ i ] ) | |
| 55 | : new SeparatorMenuItem(); | |
| 56 | ? createMenuItem( actions[ i ] ) | |
| 57 | : new SeparatorMenuItem(); | |
| 56 | 58 | } |
| 59 | ||
| 57 | 60 | return menuItems; |
| 58 | 61 | } |
| 59 | 62 | |
| 60 | public static MenuItem createMenuItem( Action action ) { | |
| 61 | MenuItem menuItem = new MenuItem( action.text ); | |
| 63 | public static MenuItem createMenuItem( final Action action ) { | |
| 64 | final MenuItem menuItem = new MenuItem( action.text ); | |
| 65 | ||
| 62 | 66 | if( action.accelerator != null ) { |
| 63 | 67 | menuItem.setAccelerator( action.accelerator ); |
| 64 | 68 | } |
| 65 | 69 | |
| 66 | 70 | if( action.icon != null ) { |
| 67 | menuItem.setGraphic( FontAwesomeIconFactory.get().createIcon( action.icon ) ); | |
| 71 | menuItem.setGraphic( | |
| 72 | FontAwesomeIconFactory.get().createIcon( action.icon ) ); | |
| 68 | 73 | } |
| 69 | 74 | |
| ... | ||
| 79 | 84 | } |
| 80 | 85 | |
| 81 | public static ToolBar createToolBar( Action... actions ) { | |
| 86 | public static ToolBar createToolBar( final Action... actions ) { | |
| 82 | 87 | return new ToolBar( createToolBarButtons( actions ) ); |
| 83 | 88 | } |
| 84 | 89 | |
| 85 | public static Node[] createToolBarButtons( Action... actions ) { | |
| 90 | public static Node[] createToolBarButtons( final Action... actions ) { | |
| 86 | 91 | Node[] buttons = new Node[ actions.length ]; |
| 87 | 92 | for( int i = 0; i < actions.length; i++ ) { |
| 88 | 93 | buttons[ i ] = (actions[ i ] != null) |
| 89 | ? createToolBarButton( actions[ i ] ) | |
| 90 | : new Separator(); | |
| 94 | ? createToolBarButton( actions[ i ] ) | |
| 95 | : new Separator(); | |
| 91 | 96 | } |
| 92 | 97 | return buttons; |
| 93 | 98 | } |
| 94 | 99 | |
| 95 | public static Button createToolBarButton( Action action ) { | |
| 96 | Button button = new Button(); | |
| 97 | button.setGraphic( FontAwesomeIconFactory.get().createIcon( action.icon, "1.2em" ) ); | |
| 100 | public static Button createToolBarButton( final Action action ) { | |
| 101 | final Button button = new Button(); | |
| 102 | button.setGraphic( | |
| 103 | FontAwesomeIconFactory | |
| 104 | .get() | |
| 105 | .createIcon( action.icon, "1.2em" ) ); | |
| 106 | ||
| 98 | 107 | String tooltip = action.text; |
| 108 | ||
| 99 | 109 | if( tooltip.endsWith( "..." ) ) { |
| 100 | 110 | tooltip = tooltip.substring( 0, tooltip.length() - 3 ); |
| 101 | 111 | } |
| 112 | ||
| 102 | 113 | if( action.accelerator != null ) { |
| 103 | 114 | tooltip += " (" + action.accelerator.getDisplayText() + ')'; |
| 104 | 115 | } |
| 116 | ||
| 105 | 117 | button.setTooltip( new Tooltip( tooltip ) ); |
| 106 | 118 | button.setFocusTraversable( false ); |
| 107 | 119 | button.setOnAction( action.action ); |
| 120 | ||
| 108 | 121 | if( action.disable != null ) { |
| 109 | 122 | button.disableProperty().bind( action.disable ); |
| 110 | 123 | } |
| 124 | ||
| 111 | 125 | return button; |
| 112 | 126 | } |
| 1 | package com.scrivenvar.util; | |
| 2 | ||
| 3 | import java.io.File; | |
| 4 | import java.net.URI; | |
| 5 | import java.net.URL; | |
| 6 | ||
| 7 | import static com.scrivenvar.Constants.DEFINITION_PROTOCOL_UNKNOWN; | |
| 8 | ||
| 9 | /** | |
| 10 | * Responsible for determining the protocol of a resource. | |
| 11 | */ | |
| 12 | public class ProtocolResolver { | |
| 13 | /** | |
| 14 | * Returns the protocol for a given URI or filename. | |
| 15 | * | |
| 16 | * @param resource Determine the protocol for this URI or filename. | |
| 17 | * @return The protocol for the given source. | |
| 18 | */ | |
| 19 | public static String getProtocol( final String resource ) { | |
| 20 | String protocol; | |
| 21 | ||
| 22 | try { | |
| 23 | final URI uri = new URI( resource ); | |
| 24 | ||
| 25 | if( uri.isAbsolute() ) { | |
| 26 | protocol = uri.getScheme(); | |
| 27 | } | |
| 28 | else { | |
| 29 | final URL url = new URL( resource ); | |
| 30 | protocol = url.getProtocol(); | |
| 31 | } | |
| 32 | } catch( final Exception e ) { | |
| 33 | // Could be HTTP, HTTPS? | |
| 34 | if( resource.startsWith( "//" ) ) { | |
| 35 | throw new IllegalArgumentException( "Relative context: " + resource ); | |
| 36 | } | |
| 37 | else { | |
| 38 | final File file = new File( resource ); | |
| 39 | protocol = getProtocol( file ); | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | return protocol; | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Returns the protocol for a given file. | |
| 48 | * | |
| 49 | * @param file Determine the protocol for this file. | |
| 50 | * @return The protocol for the given file. | |
| 51 | */ | |
| 52 | public static String getProtocol( final File file ) { | |
| 53 | String result; | |
| 54 | ||
| 55 | try { | |
| 56 | result = file.toURI().toURL().getProtocol(); | |
| 57 | } catch( final Exception e ) { | |
| 58 | result = DEFINITION_PROTOCOL_UNKNOWN; | |
| 59 | } | |
| 60 | ||
| 61 | return result; | |
| 62 | } | |
| 63 | } | |
| 1 | 64 |
| 23 | 23 | Main.menu.edit.find=_Find |
| 24 | 24 | Main.menu.edit.find.next=Find _Next |
| 25 | Main.menu.edit.preferences=_Preferences | |
| 25 | 26 | |
| 26 | 27 | Main.menu.insert=_Insert |
| ... | ||
| 36 | 37 | Main.menu.insert.link=Link... |
| 37 | 38 | Main.menu.insert.image=Image... |
| 38 | Main.menu.insert.header_1=Header 1 | |
| 39 | Main.menu.insert.header_1.prompt=header 1 | |
| 40 | Main.menu.insert.header_2=Header 2 | |
| 41 | Main.menu.insert.header_2.prompt=header 2 | |
| 42 | Main.menu.insert.header_3=Header 3 | |
| 43 | Main.menu.insert.header_3.prompt=header 3 | |
| 39 | Main.menu.insert.header.1=Header 1 | |
| 40 | Main.menu.insert.header.1.prompt=header 1 | |
| 41 | Main.menu.insert.header.2=Header 2 | |
| 42 | Main.menu.insert.header.2.prompt=header 2 | |
| 43 | Main.menu.insert.header.3=Header 3 | |
| 44 | Main.menu.insert.header.3.prompt=header 3 | |
| 44 | 45 | Main.menu.insert.unordered_list=Unordered List |
| 45 | 46 | Main.menu.insert.ordered_list=Ordered List |
| 46 | 47 | Main.menu.insert.horizontal_rule=Horizontal Rule |
| 47 | Main.menu.r=_R | |
| 48 | Main.menu.r.script=_Script | |
| 49 | Main.menu.r.directory=_Directory | |
| 50 | 48 | |
| 51 | 49 | Main.menu.help=_Help |
| ... | ||
| 60 | 58 | Main.statusbar.state.default=OK |
| 61 | 59 | Main.statusbar.parse.error={0} (near ${Main.statusbar.text.offset} {1}) |
| 60 | ||
| 61 | # ######################################################################## | |
| 62 | # Preferences | |
| 63 | # ######################################################################## | |
| 64 | ||
| 65 | Preferences.r=R | |
| 66 | Preferences.r.script=Startup Script | |
| 67 | Preferences.r.script.desc=Script runs prior to executing R statements within the document. | |
| 68 | Preferences.r.directory=Working Directory | |
| 69 | Preferences.r.directory.desc=Value assigned to $application.r.working.directory$ and usable in the startup script. | |
| 70 | ||
| 71 | Preferences.images=Images | |
| 72 | Preferences.images.directory=Relative Directory | |
| 73 | Preferences.images.directory.desc=Path prepended to embedded images referenced using local file paths. | |
| 74 | Preferences.images.suffixes=Extensions | |
| 75 | Preferences.images.suffixes.desc=Preferred order of image file types to embed, separated by spaces. | |
| 76 | ||
| 77 | Preferences.definitions=Definitions | |
| 78 | Preferences.definitions.path=File name | |
| 79 | Preferences.definitions.path.desc=Absolute path to interpolated string definitions. | |
| 62 | 80 | |
| 63 | 81 | # ######################################################################## |
| ... | ||
| 160 | 178 | Dialog.about.header=${Main.title} |
| 161 | 179 | Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber |
| 162 | ||
| 163 | # R ################################################################ | |
| 164 | ||
| 165 | # ######################################################################## | |
| 166 | # R Script | |
| 167 | # ######################################################################## | |
| 168 | ||
| 169 | Dialog.r.script.title=R Startup Script | |
| 170 | Dialog.r.script.content=Provide R statements to run prior to interpreting R statements embedded in the document. | |
| 171 | ||
| 172 | # ######################################################################## | |
| 173 | # R Directory | |
| 174 | # ######################################################################## | |
| 175 | ||
| 176 | Dialog.r.directory.title=Bootstrap Working Directory | |
| 177 | Dialog.r.directory.header=Value for $application.r.working.directory$. | |
| 178 | 180 | |
| 179 | 181 | # Options ################################################################ |
| 43 | 43 | |
| 44 | 44 | body { |
| 45 | font-family: Helvetica, arial, freesans, clean, sans-serif; | |
| 45 | font-family: serif; | |
| 46 | 46 | font-size: 14px; |
| 47 | 47 | line-height: 1.6; |
| ... | ||
| 312 | 312 | img { |
| 313 | 313 | max-width: 100% |
| 314 | } | |
| 315 | ||
| 316 | /* CARET | |
| 317 | =============================================================================*/ | |
| 318 | ||
| 319 | #CARETPOSITION { | |
| 320 | border-top: 2px solid #333; | |
| 321 | border-bottom: 2px solid #333; | |
| 322 | border-right: 1px solid #333; | |
| 323 | margin-right:-1px; | |
| 324 | animation: blink 1s linear infinite; | |
| 325 | } | |
| 326 | ||
| 327 | @keyframes blink { | |
| 328 | from { | |
| 329 | visibility:hidden; | |
| 330 | } | |
| 331 | 50% { | |
| 332 | visibility:hidden; | |
| 333 | } | |
| 334 | to { | |
| 335 | visibility:visible; | |
| 336 | } | |
| 337 | 314 | } |
| 338 | 315 | |
| 1 | 1 | # ######################################################################## |
| 2 | # | |
| 3 | 2 | # Application |
| 4 | # | |
| 5 | 3 | # ######################################################################## |
| 6 | 4 | |
| ... | ||
| 14 | 12 | |
| 15 | 13 | # ######################################################################## |
| 16 | # | |
| 17 | 14 | # Preferences |
| 18 | # | |
| 19 | 15 | # ######################################################################## |
| 20 | 16 | |
| 21 | 17 | preferences.root=com.${application.title} |
| 22 | 18 | preferences.root.state=state |
| 23 | 19 | preferences.root.options=options |
| 24 | 20 | preferences.root.definition.source=definition.source |
| 25 | 21 | |
| 26 | 22 | # ######################################################################## |
| 27 | # | |
| 28 | 23 | # File and Path References |
| 29 | # | |
| 30 | 24 | # ######################################################################## |
| 31 | ||
| 32 | 25 | file.stylesheet.scene=${application.package}/scene.css |
| 33 | 26 | file.stylesheet.markdown=${application.package}/editor/markdown.css |
| ... | ||
| 40 | 33 | file.logo.256=${application.package}/logo256.png |
| 41 | 34 | file.logo.512=${application.package}/logo512.png |
| 42 | ||
| 43 | # Startup script for R | |
| 44 | file.r.startup=/${application.package}/startup.R | |
| 45 | 35 | |
| 46 | # Default filename when a new file is created. | |
| 36 | # Default file name when a new file is created. | |
| 47 | 37 | # This ensures that the file type can always be |
| 48 | 38 | # discerned so that the correct type of variable |
| 49 | 39 | # reference can be inserted. |
| 50 | 40 | file.default=untitled.md |
| 51 | 41 | file.definition.default=variables.yaml |
| 52 | ||
| 53 | # ######################################################################## | |
| 54 | # | |
| 55 | # Caret token | |
| 56 | # | |
| 57 | # ######################################################################## | |
| 58 | caret.token.base=CARETPOSITION | |
| 59 | caret.token.markdown=%${constant.caret.token.base}% | |
| 60 | caret.token.html=<span id="${caret.token.base}"></span> | |
| 61 | 42 | |
| 62 | 43 | # ######################################################################## |
| 63 | # | |
| 64 | # Filename Extensions | |
| 65 | # | |
| 44 | # File name Extensions | |
| 66 | 45 | # ######################################################################## |
| 67 | 46 | |
| 68 | # Comma-separated list of definition filename extensions. | |
| 47 | # Comma-separated list of definition file name extensions. | |
| 69 | 48 | definition.file.ext.json=*.json |
| 70 | 49 | definition.file.ext.toml=*.toml |
| 71 | 50 | definition.file.ext.yaml=*.yml,*.yaml |
| 72 | 51 | definition.file.ext.properties=*.properties,*.props |
| 73 | 52 | |
| 74 | # Comma-separated list of filename extensions. | |
| 53 | # Comma-separated list of file name extensions. | |
| 75 | 54 | file.ext.rmarkdown=*.Rmd |
| 76 | 55 | file.ext.rxml=*.Rxml |
| 77 | 56 | file.ext.source=*.md,*.markdown,*.mkdown,*.mdown,*.mkdn,*.mkd,*.mdwn,*.mdtxt,*.mdtext,*.text,*.txt,${file.ext.rmarkdown},${file.ext.rxml} |
| 78 | 57 | file.ext.definition=${definition.file.ext.yaml} |
| 79 | 58 | file.ext.xml=*.xml,${file.ext.rxml} |
| 80 | 59 | file.ext.all=*.* |
| 60 | ||
| 61 | # File name extension search order for images. | |
| 62 | file.ext.image.order=svg pdf png jpg tiff | |
| 81 | 63 | |
| 82 | 64 | # ######################################################################## |
| 83 | # | |
| 84 | 65 | # Variable Name Editor |
| 85 | # | |
| 86 | 66 | # ######################################################################## |
| 87 | 67 | |
| 88 | 68 | # Maximum number of characters for a variable name. A variable is defined |
| 89 | 69 | # as one or more non-whitespace characters up to this maximum length. |
| 90 | 70 | editor.variable.maxLength=256 |
| 91 | 71 | |
| 92 | 72 | # ######################################################################## |
| 93 | # | |
| 94 | 73 | # Dialog Preferences |
| 95 | # | |
| 96 | 74 | # ######################################################################## |
| 97 | 75 | |
| 98 | # docs.oracle.com/javase/8/javafx/api/javafx/scene/control/ButtonBar.html | |
| 99 | 76 | dialog.alert.button.order.mac=L_HE+U+FBIX_NCYOA_R |
| 100 | 77 | dialog.alert.button.order.linux=L_HE+UNYACBXIO_R |
| 101 | 78 | dialog.alert.button.order.windows=L_E+U+FBXI_YNOCAH_R |
| 102 | 79 | |
| 103 | 80 | # Ensures a consistent button order for alert dialogs across platforms (because |
| 104 | # the default button order on Linux defies all logic). Power to the people. | |
| 81 | # the default button order on Linux defies all logic). | |
| 105 | 82 | dialog.alert.button.order=${dialog.alert.button.order.windows} |
| 106 | 83 | |