| 1 | 1 | # License |
| 2 | 2 | |
| 3 | Copyright 2020 White Magic Software, Ltd. | |
| 4 | ||
| 5 | Copyright 2015 Karl Tauber | |
| 3 | Copyright 2023 White Magic Software, Ltd. | |
| 6 | 4 | |
| 7 | 5 | All rights reserved. |
| 100 | 100 | implementation 'org.fxmisc.flowless:flowless:0.7.2' |
| 101 | 101 | implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3' |
| 102 | implementation 'com.miglayout:miglayout-javafx:11.3' | |
| 103 | 102 | implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.16.0' |
| 104 | 103 | implementation 'com.panemu:tiwulfx-dock:0.2' |
| ... | ||
| 145 | 144 | // Misc. |
| 146 | 145 | implementation 'org.ahocorasick:ahocorasick:0.6.3' |
| 147 | implementation 'org.apache.commons:commons-lang3:3.14.0' | |
| 148 | 146 | implementation 'com.github.albfernandez:juniversalchardet:2.4.0' |
| 149 | 147 | implementation 'jakarta.validation:jakarta.validation-api:3.0.2' |
| 150 | 148 | implementation 'org.greenrobot:eventbus-java:3.3.1' |
| 151 | 149 | |
| 152 | 150 | // Command-line parsing |
| 153 | 151 | implementation "info.picocli:picocli:${v_picocli}" |
| 154 | 152 | annotationProcessor "info.picocli:picocli-codegen:${v_picocli}" |
| 155 | 153 | |
| 156 | // KeenQuotes, KeenType, KeenSpell, word split. | |
| 154 | // KeenQuotes, KeenType, KeenSpell, KeenCount. | |
| 157 | 155 | implementation fileTree( include: ['**/*.jar'], dir: 'libs' ) |
| 158 | 156 | |
| 63 | 63 | |
| 64 | 64 | RUN \ |
| 65 | apk add -t py3-cssselect && \ | |
| 66 | apk add -t py3-lxml && \ | |
| 67 | apk add -t py3-numpy && \ | |
| 65 | 68 | apk --update --no-cache \ |
| 66 | 69 | add ca-certificates curl fontconfig inkscape rsync && \ |
| 112 | 112 | local -r remote_path="${repository}/${remote_file}" |
| 113 | 113 | |
| 114 | $log "Publishing to ${remote_path}" | |
| 114 | $log "Publishing ${CONTAINER_IMAGE_FILE} to ${remote_path}" | |
| 115 | 115 | |
| 116 | 116 | # Path to the repository. |
| 1 | 1 | # Credits |
| 2 | 2 | |
| 3 | * Karl Tauber: [Markdown Writer FX](https://github.com/JFormDesigner/markdown-writer-fx) | |
| 4 | * Tomas Mikula: [RichTextFX](https://github.com/TomasMikula/RichTextFX), [ReactFX](https://github.com/TomasMikula/ReactFX), [WellBehavedFX](https://github.com/TomasMikula/WellBehavedFX), [Flowless](https://github.com/TomasMikula/Flowless), and [UndoFX](https://github.com/TomasMikula/UndoFX) | |
| 5 | * Mikael Grev: [MigLayout](http://www.miglayout.com/) | |
| 6 | * Tom Eugelink: [MigPane](https://github.com/mikaelgrev/miglayout/blob/master/javafx/src/main/java/org/tbee/javafx/scene/layout/fxml/MigPane.java) | |
| 3 | Using libraries from: | |
| 4 | ||
| 5 | * Tomas Mikula: [RichTextFX](https://github.com/TomasMikula/RichTextFX), [WellBehavedFX](https://github.com/TomasMikula/WellBehavedFX), [Flowless](https://github.com/TomasMikula/Flowless), and [UndoFX](https://github.com/TomasMikula/UndoFX) | |
| 7 | 6 | * Jens Deters: [FontAwesomeFX](https://bitbucket.org/Jerady/fontawesomefx) |
| 8 | * Dieter Holz, [PreferencesFX](https://github.com/dlsc-software-consulting-gmbh/PreferencesFX) | |
| 9 | * David Croft, [File Preferences](http://www.davidc.net/programming/java/java-preferences-using-file-backing-store) | |
| 10 | * Alex Bertram, [Renjin](https://www.renjin.org/) | |
| 7 | * Dieter Holz: [PreferencesFX](https://github.com/dlsc-software-consulting-gmbh/PreferencesFX) | |
| 8 | * David Croft: [File Preferences](http://www.davidc.net/programming/java/java-preferences-using-file-backing-store) | |
| 9 | * Alex Bertram: [Renjin](https://www.renjin.org/) | |
| 11 | 10 | * Vladimir Schneider: [flexmark](https://github.com/vsch/flexmark-java) |
| 12 | * Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet) | |
| 11 | * Alberto Fernández, Shy Shalom, Kohei Taketa: [juniversalchardet](https://github.com/takscape/juniversalchardet) | |
| 12 | * Morten Nobel-Jørgensen: [Java Image Scaling](https://github.com/mortennobel/java-image-scaling) | |
| 13 | ||
| 14 | Inspired by: | |
| 13 | 15 | |
| 16 | * Karl Tauber: [Markdown Writer FX](https://github.com/JFormDesigner/markdown-writer-fx) | |
| 14 | 17 |
| 4 | 4 | All rights reserved. |
| 5 | 5 | |
| 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: | |
| 7 | ||
| 8 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. | |
| 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the | |
| 7 | following conditions are met: | |
| 9 | 8 | |
| 10 | Neither the name of the <ORGANIZATION> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following | |
| 10 | disclaimer. | |
| 11 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following | |
| 12 | disclaimer in the documentation and/or other materials provided with the distribution. | |
| 13 | Neither the name of the <ORGANIZATION> nor the names of its contributors may be used to endorse or promote products | |
| 14 | derived from this software without specific prior written permission. | |
| 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, | |
| 16 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | |
| 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 18 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | |
| 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, | |
| 20 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 11 | 21 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 12 | ||
| 13 | 22 |
| 19 | 19 | |
| 20 | 20 | Contributor(s): |
| 21 | Alberto Fernández <infjaf@gmail.com> | |
| 21 | 22 | Shy Shalom <shooshX@gmail.com> |
| 22 | 23 | Kohei TAKETA <k-tak@void.in> (Java port) |
| ... | ||
| 33 | 34 | the provisions above, a recipient may use your version of this file under |
| 34 | 35 | the terms of any one of the MPL, the GPL or the LGPL. |
| 35 | ||
| 36 | 36 | |
| 1 | Copyright © 2020 Mark Raynsford <code@io7m.com> http://io7m.com | |
| 2 | ||
| 3 | Permission to use, copy, modify, and/or distribute this software for any | |
| 4 | purpose with or without fee is hereby granted, provided that the above | |
| 5 | copyright notice and this permission notice appear in all copies. | |
| 6 | ||
| 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
| 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
| 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |
| 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
| 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |
| 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |
| 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| 14 | 1 |
| 1 | Copyright (c) 2015 Karl Tauber <karl@jformdesigner.com> | |
| 2 | All rights reserved. | |
| 3 | ||
| 4 | Redistribution and use in source and binary forms, with or without | |
| 5 | modification, are permitted provided that the following conditions are met: | |
| 6 | ||
| 7 | * Redistributions of source code must retain the above copyright | |
| 8 | notice, this list of conditions and the following disclaimer. | |
| 9 | ||
| 10 | * Redistributions in binary form must reproduce the above copyright | |
| 11 | notice, this list of conditions and the following disclaimer in the | |
| 12 | documentation and/or other materials provided with the distribution. | |
| 13 | ||
| 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 15 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 16 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 17 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 18 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 19 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 20 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 21 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 22 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 25 | 1 |
| 1 | Copyright (c) 2000 Mikael Grev | |
| 2 | All rights reserved. | |
| 3 | ||
| 4 | Redistribution and use in source and binary forms, with or without | |
| 5 | modification, are permitted provided that the following conditions | |
| 6 | are met: | |
| 7 | 1. Redistributions of source code must retain the above copyright | |
| 8 | notice, this list of conditions and the following disclaimer. | |
| 9 | 2. Redistributions in binary form must reproduce the above copyright | |
| 10 | notice, this list of conditions and the following disclaimer in the | |
| 11 | documentation and/or other materials provided with the distribution. | |
| 12 | 3. The name of the author may not be used to endorse or promote products | |
| 13 | derived from this software without specific prior written permission. | |
| 14 | ||
| 15 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR | |
| 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES | |
| 17 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. | |
| 18 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, | |
| 19 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT | |
| 20 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 21 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 22 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF | |
| 24 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 25 | ||
| 26 | 1 |
| 1 | Copyright (c) 2013-2014, Tomas Mikula | |
| 2 | All rights reserved. | |
| 3 | ||
| 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: | |
| 5 | ||
| 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. | |
| 7 | ||
| 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. | |
| 9 | ||
| 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 11 | 1 |
| 173 | 173 | out( "%n%s version %s", APP_TITLE, APP_VERSION ); |
| 174 | 174 | out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ); |
| 175 | out( "Portions copyright 2015-2020 Karl Tauber.%n" ); | |
| 176 | 175 | } |
| 177 | 176 |
| 201 | 201 | mStatistics = new DocumentStatistics( workspace ); |
| 202 | 202 | |
| 203 | mTextEditor.addListener( ( c, o, n ) -> { | |
| 204 | if( o != null ) { | |
| 205 | removeProcessor( o ); | |
| 206 | } | |
| 207 | ||
| 208 | if( n != null ) { | |
| 209 | mPreview.setBaseUri( n.getPath() ); | |
| 210 | updateProcessors( n ); | |
| 211 | process( n ); | |
| 212 | } | |
| 213 | } ); | |
| 214 | ||
| 215 | mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); | |
| 216 | mDefinitionEditor.set( createDefinitionEditor( workspace ) ); | |
| 217 | mVariableNameInjector = new VariableNameInjector( workspace ); | |
| 218 | mRBootstrapController = new RBootstrapController( | |
| 219 | workspace, mDefinitionEditor.get()::getDefinitions | |
| 220 | ); | |
| 221 | ||
| 222 | // If the user modifies the definitions, re-process the variables. | |
| 223 | mDefinitionEditor.addListener( ( c, o, n ) -> { | |
| 224 | final var textEditor = getTextEditor(); | |
| 225 | ||
| 226 | if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) { | |
| 227 | mRBootstrapController.update(); | |
| 228 | } | |
| 229 | ||
| 230 | process( textEditor ); | |
| 231 | } ); | |
| 232 | ||
| 233 | open( collect( getRecentFiles() ) ); | |
| 234 | viewPreview(); | |
| 235 | setDividerPositions( calculateDividerPositions() ); | |
| 236 | ||
| 237 | // Once the main scene's window regains focus, update the active definition | |
| 238 | // editor to the currently selected tab. | |
| 239 | runLater( () -> getWindow().setOnCloseRequest( event -> { | |
| 240 | // Order matters: Open file names must be persisted before closing all. | |
| 241 | mWorkspace.save(); | |
| 242 | ||
| 243 | if( closeAll() ) { | |
| 244 | exit(); | |
| 245 | terminate( 0 ); | |
| 246 | } | |
| 247 | ||
| 248 | event.consume(); | |
| 249 | } ) ); | |
| 250 | ||
| 251 | register( this ); | |
| 252 | initAutosave( workspace ); | |
| 253 | ||
| 254 | restoreSession(); | |
| 255 | runLater( this::restoreFocus ); | |
| 256 | ||
| 257 | mInstallWizard = new TypesetterInstaller( workspace ); | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Called when spellchecking can be run. This will reload the dictionary | |
| 262 | * into memory once, and then re-use it for all the existing text editors. | |
| 263 | * | |
| 264 | * @param event The event to process, having a populated word-frequency map. | |
| 265 | */ | |
| 266 | @Subscribe | |
| 267 | public void handle( final LexiconLoadedEvent event ) { | |
| 268 | final var lexicon = event.getLexicon(); | |
| 269 | ||
| 270 | try { | |
| 271 | final var checker = SymSpellSpeller.forLexicon( lexicon ); | |
| 272 | mSpellChecker.set( checker ); | |
| 273 | } catch( final Exception ex ) { | |
| 274 | clue( ex ); | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | @Subscribe | |
| 279 | public void handle( final TextEditorFocusEvent event ) { | |
| 280 | mTextEditor.set( event.get() ); | |
| 281 | } | |
| 282 | ||
| 283 | @Subscribe | |
| 284 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 285 | mDefinitionEditor.set( event.get() ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Typically called when a file name is clicked in the preview panel. | |
| 290 | * | |
| 291 | * @param event The event to process, must contain a valid file reference. | |
| 292 | */ | |
| 293 | @Subscribe | |
| 294 | public void handle( final FileOpenEvent event ) { | |
| 295 | final File eventFile; | |
| 296 | final var eventUri = event.getUri(); | |
| 297 | ||
| 298 | if( eventUri.isAbsolute() ) { | |
| 299 | eventFile = new File( eventUri.getPath() ); | |
| 300 | } | |
| 301 | else { | |
| 302 | final var activeFile = getTextEditor().getFile(); | |
| 303 | final var parent = activeFile.getParentFile(); | |
| 304 | ||
| 305 | if( parent == null ) { | |
| 306 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 307 | return; | |
| 308 | } | |
| 309 | else { | |
| 310 | final var parentPath = parent.getAbsolutePath(); | |
| 311 | eventFile = toFile( Path.of( parentPath, eventUri.getPath() ) ); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | final var mediaType = MediaTypeExtension.fromFile( eventFile ); | |
| 316 | ||
| 317 | runLater( () -> { | |
| 318 | // Open text files locally. | |
| 319 | if( mediaType.isType( TEXT ) ) { | |
| 320 | open( eventFile ); | |
| 321 | } | |
| 322 | else { | |
| 323 | try { | |
| 324 | // Delegate opening all other file types to the operating system. | |
| 325 | getDesktop().open( eventFile ); | |
| 326 | } catch( final Exception ex ) { | |
| 327 | clue( ex ); | |
| 328 | } | |
| 329 | } | |
| 330 | } ); | |
| 331 | } | |
| 332 | ||
| 333 | @Subscribe | |
| 334 | public void handle( final CaretNavigationEvent event ) { | |
| 335 | runLater( () -> { | |
| 336 | final var textArea = getTextEditor(); | |
| 337 | textArea.moveTo( event.getOffset() ); | |
| 338 | textArea.requestFocus(); | |
| 339 | } ); | |
| 340 | } | |
| 341 | ||
| 342 | @Subscribe | |
| 343 | public void handle( final InsertDefinitionEvent<String> event ) { | |
| 344 | final var leaf = event.getLeaf(); | |
| 345 | final var editor = mTextEditor.get(); | |
| 346 | ||
| 347 | mVariableNameInjector.insert( editor, leaf ); | |
| 348 | } | |
| 349 | ||
| 350 | private void initAutosave( final Workspace workspace ) { | |
| 351 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 352 | ||
| 353 | rate.addListener( | |
| 354 | ( c, o, n ) -> { | |
| 355 | final var taskRef = mSaveTask.get(); | |
| 356 | ||
| 357 | // Prevent multiple auto-saves from running. | |
| 358 | if( taskRef != null ) { | |
| 359 | taskRef.cancel( false ); | |
| 360 | } | |
| 361 | ||
| 362 | initAutosave( rate ); | |
| 363 | } | |
| 364 | ); | |
| 365 | ||
| 366 | // Start the save listener (avoids duplicating some code). | |
| 367 | initAutosave( rate ); | |
| 368 | } | |
| 369 | ||
| 370 | private void initAutosave( final IntegerProperty rate ) { | |
| 371 | mSaveTask.set( | |
| 372 | mSaver.scheduleAtFixedRate( | |
| 373 | () -> { | |
| 374 | if( getTextEditor().isModified() ) { | |
| 375 | // Ensure the modified indicator is cleared by running on EDT. | |
| 376 | runLater( this::save ); | |
| 377 | } | |
| 378 | }, 0, rate.intValue(), SECONDS | |
| 379 | ) | |
| 380 | ); | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * TODO: Load divider positions from exported settings, see | |
| 385 | * {@link #collect(SetProperty)} comment. | |
| 386 | */ | |
| 387 | private double[] calculateDividerPositions() { | |
| 388 | final var ratio = 100f / getItems().size() / 100; | |
| 389 | final var positions = getDividerPositions(); | |
| 390 | ||
| 391 | for( int i = 0; i < positions.length; i++ ) { | |
| 392 | positions[ i ] = ratio * i; | |
| 393 | } | |
| 394 | ||
| 395 | return positions; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * Opens all the files into the application, provided the paths are unique. | |
| 400 | * This may only be called for any type of files that a user can edit | |
| 401 | * (i.e., update and persist), such as definitions and text files. | |
| 402 | * | |
| 403 | * @param files The list of files to open. | |
| 404 | */ | |
| 405 | public void open( final List<File> files ) { | |
| 406 | files.forEach( this::open ); | |
| 407 | } | |
| 408 | ||
| 409 | /** | |
| 410 | * This opens the given file. Since the preview pane is not a file that | |
| 411 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 412 | * This will exit early if the given file is not a regular file (i.e., a | |
| 413 | * directory). | |
| 414 | * | |
| 415 | * @param inputFile The file to open. | |
| 416 | */ | |
| 417 | private void open( final File inputFile ) { | |
| 418 | // Prevent opening directories (a non-existent "untitled.md" is fine). | |
| 419 | if( !inputFile.isFile() && inputFile.exists() ) { | |
| 420 | return; | |
| 421 | } | |
| 422 | ||
| 423 | final var mediaType = fromFilename( inputFile ); | |
| 424 | ||
| 425 | // Only allow opening text files. | |
| 426 | if( !mediaType.isType( TEXT ) ) { | |
| 427 | return; | |
| 428 | } | |
| 429 | ||
| 430 | final var tab = createTab( inputFile ); | |
| 431 | final var node = tab.getContent(); | |
| 432 | final var tabPane = obtainTabPane( mediaType ); | |
| 433 | ||
| 434 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 435 | tabPane.setFocusTraversable( false ); | |
| 436 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 437 | tabPane.getTabs().add( tab ); | |
| 438 | ||
| 439 | // Attach the tab scene factory for new tab panes. | |
| 440 | if( !getItems().contains( tabPane ) ) { | |
| 441 | addTabPane( | |
| 442 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 443 | ); | |
| 444 | } | |
| 445 | ||
| 446 | if( inputFile.isFile() ) { | |
| 447 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 448 | } | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Gives focus to the most recently edited document and attempts to move | |
| 453 | * the caret to the most recently known offset into said document. | |
| 454 | */ | |
| 455 | private void restoreSession() { | |
| 456 | final var workspace = getWorkspace(); | |
| 457 | final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); | |
| 458 | final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); | |
| 459 | ||
| 460 | for( final var pane : mTabPanes ) { | |
| 461 | for( final var tab : pane.getTabs() ) { | |
| 462 | final var tooltip = tab.getTooltip(); | |
| 463 | ||
| 464 | if( tooltip != null ) { | |
| 465 | final var tabName = tooltip.getText(); | |
| 466 | final var fileName = file.get().toString(); | |
| 467 | ||
| 468 | if( tabName.equalsIgnoreCase( fileName ) ) { | |
| 469 | final var node = tab.getContent(); | |
| 470 | ||
| 471 | pane.getSelectionModel().select( tab ); | |
| 472 | node.requestFocus(); | |
| 473 | ||
| 474 | if( node instanceof TextEditor editor ) { | |
| 475 | runLater( () -> editor.moveTo( offset.getValue() ) ); | |
| 476 | } | |
| 477 | ||
| 478 | break; | |
| 479 | } | |
| 480 | } | |
| 481 | } | |
| 482 | } | |
| 483 | } | |
| 484 | ||
| 485 | /** | |
| 486 | * Sets the focus to the middle pane, which contains the text editor tabs. | |
| 487 | */ | |
| 488 | private void restoreFocus() { | |
| 489 | // Work around a bug where focusing directly on the middle pane results | |
| 490 | // in the R engine not loading variables properly. | |
| 491 | mTabPanes.get( 0 ).requestFocus(); | |
| 492 | ||
| 493 | // This is the only line that should be required. | |
| 494 | mTabPanes.get( 1 ).requestFocus(); | |
| 495 | } | |
| 496 | ||
| 497 | /** | |
| 498 | * Opens a new text editor document using a document file name that doesn't | |
| 499 | * clash with an existing document. | |
| 500 | */ | |
| 501 | public void newTextEditor() { | |
| 502 | final String key = "file.default.document."; | |
| 503 | final String prefix = Constants.get( STR."\{key}prefix" ); | |
| 504 | final String suffix = Constants.get( STR."\{key}suffix" ); | |
| 505 | ||
| 506 | File file = new File( STR."\{prefix}.\{suffix}" ); | |
| 507 | int i = 0; | |
| 508 | ||
| 509 | while( file.exists() && i++ < 100 ) { | |
| 510 | file = new File( STR."\{prefix}-\{i}.\{suffix}" ); | |
| 511 | } | |
| 512 | ||
| 513 | open( file ); | |
| 514 | } | |
| 515 | ||
| 516 | /** | |
| 517 | * Opens a new definition editor document using the default definition | |
| 518 | * file name. | |
| 519 | */ | |
| 520 | @SuppressWarnings( "unused" ) | |
| 521 | public void newDefinitionEditor() { | |
| 522 | open( DEFINITION_DEFAULT ); | |
| 523 | } | |
| 524 | ||
| 525 | /** | |
| 526 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 527 | * that they save themselves. | |
| 528 | */ | |
| 529 | public void saveAll() { | |
| 530 | iterateEditors( this::save ); | |
| 531 | } | |
| 532 | ||
| 533 | /** | |
| 534 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 535 | * checking if modified first because if the user swaps external media from | |
| 536 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 537 | * the user: save always re-saves. Also, it's less code. | |
| 538 | */ | |
| 539 | public void save() { | |
| 540 | save( getTextEditor() ); | |
| 541 | } | |
| 542 | ||
| 543 | /** | |
| 544 | * Saves the active {@link TextEditor} under a new name. | |
| 545 | * | |
| 546 | * @param files The new active editor {@link File} reference, must contain | |
| 547 | * at least one element. | |
| 548 | */ | |
| 549 | public void saveAs( final List<File> files ) { | |
| 550 | assert files != null; | |
| 551 | assert !files.isEmpty(); | |
| 552 | final var editor = getTextEditor(); | |
| 553 | final var tab = getTab( editor ); | |
| 554 | final var file = files.get( 0 ); | |
| 555 | ||
| 556 | // If the file type has changed, refresh the processors. | |
| 557 | final var mediaType = fromFilename( file ); | |
| 558 | final var typeChanged = !editor.isMediaType( mediaType ); | |
| 559 | ||
| 560 | if( typeChanged ) { | |
| 561 | removeProcessor( editor ); | |
| 562 | } | |
| 563 | ||
| 564 | editor.rename( file ); | |
| 565 | tab.ifPresent( t -> { | |
| 566 | t.setText( editor.getFilename() ); | |
| 567 | t.setTooltip( createTooltip( file ) ); | |
| 568 | } ); | |
| 569 | ||
| 570 | if( typeChanged ) { | |
| 571 | updateProcessors( editor ); | |
| 572 | process( editor ); | |
| 573 | } | |
| 574 | ||
| 575 | save(); | |
| 576 | } | |
| 577 | ||
| 578 | /** | |
| 579 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 580 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 581 | * | |
| 582 | * @param resource The resource to export. | |
| 583 | */ | |
| 584 | private void save( final TextResource resource ) { | |
| 585 | try { | |
| 586 | resource.save(); | |
| 587 | } catch( final Exception ex ) { | |
| 588 | clue( ex ); | |
| 589 | sNotifier.alert( | |
| 590 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 591 | ); | |
| 592 | } | |
| 593 | } | |
| 594 | ||
| 595 | /** | |
| 596 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 597 | * | |
| 598 | * @return {@code true} when all editors, modified or otherwise, were | |
| 599 | * permitted to close; {@code false} when one or more editors were modified | |
| 600 | * and the user requested no closing. | |
| 601 | */ | |
| 602 | public boolean closeAll() { | |
| 603 | var closable = true; | |
| 604 | ||
| 605 | for( final var tabPane : mTabPanes ) { | |
| 606 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 607 | ||
| 608 | while( tabIterator.hasNext() ) { | |
| 609 | final var tab = tabIterator.next(); | |
| 610 | final var resource = tab.getContent(); | |
| 611 | ||
| 612 | // The definition panes auto-save, so being specific here prevents | |
| 613 | // closing the definitions in the situation where the user wants to | |
| 614 | // continue editing (i.e., possibly save unsaved work). | |
| 615 | if( !(resource instanceof TextEditor) ) { | |
| 616 | continue; | |
| 617 | } | |
| 618 | ||
| 619 | if( canClose( (TextEditor) resource ) ) { | |
| 620 | tabIterator.remove(); | |
| 621 | close( tab ); | |
| 622 | } | |
| 623 | else { | |
| 624 | closable = false; | |
| 625 | } | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | return closable; | |
| 630 | } | |
| 631 | ||
| 632 | /** | |
| 633 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 634 | * event. | |
| 635 | * | |
| 636 | * @param tab The {@link Tab} that was closed. | |
| 637 | */ | |
| 638 | private void close( final Tab tab ) { | |
| 639 | assert tab != null; | |
| 640 | ||
| 641 | final var handler = tab.getOnClosed(); | |
| 642 | ||
| 643 | if( handler != null ) { | |
| 644 | handler.handle( new ActionEvent() ); | |
| 645 | } | |
| 646 | } | |
| 647 | ||
| 648 | /** | |
| 649 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 650 | */ | |
| 651 | public void close() { | |
| 652 | final var editor = getTextEditor(); | |
| 653 | ||
| 654 | if( canClose( editor ) ) { | |
| 655 | close( editor ); | |
| 656 | removeProcessor( editor ); | |
| 657 | } | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Closes the given {@link TextResource}. This must not be called from within | |
| 662 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 663 | * concurrent modification exception be thrown. | |
| 664 | * | |
| 665 | * @param resource The {@link TextResource} to close, without confirming with | |
| 666 | * the user. | |
| 667 | */ | |
| 668 | private void close( final TextResource resource ) { | |
| 669 | getTab( resource ).ifPresent( | |
| 670 | tab -> { | |
| 671 | close( tab ); | |
| 672 | tab.getTabPane().getTabs().remove( tab ); | |
| 673 | } | |
| 674 | ); | |
| 675 | } | |
| 676 | ||
| 677 | /** | |
| 678 | * Answers whether the given {@link TextResource} may be closed. | |
| 679 | * | |
| 680 | * @param editor The {@link TextResource} to try closing. | |
| 681 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 682 | * the user has requested to keep the editor open. | |
| 683 | */ | |
| 684 | private boolean canClose( final TextResource editor ) { | |
| 685 | final var editorTab = getTab( editor ); | |
| 686 | final var canClose = new AtomicBoolean( true ); | |
| 687 | ||
| 688 | if( editor.isModified() ) { | |
| 689 | final var filename = new StringBuilder(); | |
| 690 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | |
| 691 | ||
| 692 | final var message = sNotifier.createNotification( | |
| 693 | Messages.get( "Alert.file.close.title" ), | |
| 694 | Messages.get( "Alert.file.close.text" ), | |
| 695 | filename.toString() | |
| 696 | ); | |
| 697 | ||
| 698 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 699 | ||
| 700 | dialog.showAndWait().ifPresent( | |
| 701 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 702 | ); | |
| 703 | } | |
| 704 | ||
| 705 | return canClose.get(); | |
| 706 | } | |
| 707 | ||
| 708 | private void iterateEditors( final Consumer<TextEditor> consumer ) { | |
| 709 | mTabPanes.forEach( | |
| 710 | tp -> tp.getTabs().forEach( tab -> { | |
| 711 | final var node = tab.getContent(); | |
| 712 | ||
| 713 | if( node instanceof final TextEditor editor ) { | |
| 714 | consumer.accept( editor ); | |
| 715 | } | |
| 716 | } ) | |
| 717 | ); | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 722 | */ | |
| 723 | public void viewPreview() { | |
| 724 | addTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 725 | } | |
| 726 | ||
| 727 | /** | |
| 728 | * Adds the document outline tab to its own, singular tab pane. | |
| 729 | */ | |
| 730 | public void viewOutline() { | |
| 731 | addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 732 | } | |
| 733 | ||
| 734 | public void viewStatistics() { | |
| 735 | addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 736 | } | |
| 737 | ||
| 738 | public void viewFiles() { | |
| 739 | try { | |
| 740 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 741 | final var fileManager = factory.createModeless(); | |
| 742 | addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 743 | } catch( final Exception ex ) { | |
| 744 | clue( ex ); | |
| 745 | } | |
| 746 | } | |
| 747 | ||
| 748 | public void viewRefresh() { | |
| 749 | mPreview.refresh(); | |
| 750 | Engine.clear(); | |
| 751 | mRBootstrapController.update(); | |
| 752 | } | |
| 753 | ||
| 754 | private void addTab( | |
| 755 | final Node node, final MediaType mediaType, final String key ) { | |
| 756 | final var tabPane = obtainTabPane( mediaType ); | |
| 757 | ||
| 758 | for( final var tab : tabPane.getTabs() ) { | |
| 759 | if( tab.getContent() == node ) { | |
| 760 | return; | |
| 761 | } | |
| 762 | } | |
| 763 | ||
| 764 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 765 | addTabPane( tabPane ); | |
| 766 | } | |
| 767 | ||
| 768 | /** | |
| 769 | * Returns the tab that contains the given {@link TextEditor}. | |
| 770 | * | |
| 771 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 772 | * @return The first tab having content that matches the given tab. | |
| 773 | */ | |
| 774 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 775 | return mTabPanes.stream() | |
| 776 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 777 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 778 | .findFirst(); | |
| 779 | } | |
| 780 | ||
| 781 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 782 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 783 | ||
| 784 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 785 | ||
| 786 | return editor; | |
| 787 | } | |
| 788 | ||
| 789 | /** | |
| 790 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 791 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 792 | * Upon changing, the variables are interpolated and the active text editor | |
| 793 | * is refreshed. | |
| 794 | * | |
| 795 | * @param workspace Has the most recently edited definitions file name. | |
| 796 | * @return A newly configured property that represents the active | |
| 797 | * {@link DefinitionEditor}, never {@code null}. | |
| 798 | */ | |
| 799 | private TextDefinition createDefinitionEditor( | |
| 800 | final Workspace workspace ) { | |
| 801 | final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION ); | |
| 802 | final var filename = fileProperty.get(); | |
| 803 | final SetProperty<String> recent = workspace.setsProperty( | |
| 804 | KEY_UI_RECENT_OPEN_PATH | |
| 805 | ); | |
| 806 | ||
| 807 | // Open the most recently used YAML definition file. | |
| 808 | for( final var recentFile : recent.get() ) { | |
| 809 | if( recentFile.endsWith( filename.toString() ) ) { | |
| 810 | return createDefinitionEditor( new File( recentFile ) ); | |
| 811 | } | |
| 812 | } | |
| 813 | ||
| 814 | return createDefaultDefinitionEditor(); | |
| 815 | } | |
| 816 | ||
| 817 | private TextDefinition createDefaultDefinitionEditor() { | |
| 818 | final var transformer = createTreeTransformer(); | |
| 819 | return new DefinitionEditor( transformer ); | |
| 820 | } | |
| 821 | ||
| 822 | private TreeTransformer createTreeTransformer() { | |
| 823 | return new YamlTreeTransformer(); | |
| 824 | } | |
| 825 | ||
| 826 | private Tab createTab( final String filename, final Node node ) { | |
| 827 | return new DetachableTab( filename, node ); | |
| 828 | } | |
| 829 | ||
| 830 | private Tab createTab( final File file ) { | |
| 831 | final var r = createTextResource( file ); | |
| 832 | final var filename = r.getFilename(); | |
| 833 | final var tab = createTab( filename, r.getNode() ); | |
| 834 | ||
| 835 | r.modifiedProperty().addListener( | |
| 836 | ( c, o, n ) -> tab.setText( filename + (n ? "*" : "") ) | |
| 837 | ); | |
| 838 | ||
| 839 | // This is called when either the tab is closed by the user clicking on | |
| 840 | // the tab's close icon or when closing (all) from the file menu. | |
| 841 | tab.setOnClosed( | |
| 842 | __ -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 843 | ); | |
| 844 | ||
| 845 | // When closing a tab, give focus to the newly revealed tab. | |
| 846 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 847 | if( n != null && n ) { | |
| 848 | final var pane = tab.getTabPane(); | |
| 849 | ||
| 850 | if( pane != null ) { | |
| 851 | pane.requestFocus(); | |
| 852 | } | |
| 853 | } | |
| 854 | } ); | |
| 855 | ||
| 856 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 857 | if( nPane != null ) { | |
| 858 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 859 | if( n != null && n ) { | |
| 860 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 861 | final var node = selected.getContent(); | |
| 862 | node.requestFocus(); | |
| 863 | } | |
| 864 | } ); | |
| 865 | } | |
| 866 | } ); | |
| 867 | ||
| 868 | return tab; | |
| 869 | } | |
| 870 | ||
| 871 | /** | |
| 872 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 873 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 874 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 875 | * be replaced by such a class. | |
| 876 | * <p> | |
| 877 | * When binning the files, this makes sure that at least one file exists | |
| 878 | * for every type. If the user has opted to close a particular type (such | |
| 879 | * as the definition pane), the view will suppressed elsewhere. | |
| 880 | * </p> | |
| 881 | * <p> | |
| 882 | * The order that the binned files are returned will be reflected in the | |
| 883 | * order that the corresponding panes are rendered in the UI. | |
| 884 | * </p> | |
| 885 | * | |
| 886 | * @param paths The file paths to bin according to their type. | |
| 887 | * @return An in-order list of files, first by structured definition files, | |
| 888 | * then by plain text documents. | |
| 889 | */ | |
| 890 | private List<File> collect( final SetProperty<String> paths ) { | |
| 891 | // Treat all files destined for the text editor as plain text documents | |
| 892 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 893 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 894 | final Function<MediaType, MediaType> bin = | |
| 895 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 896 | ||
| 897 | // Create two groups: YAML files and plain text files. The order that | |
| 898 | // the elements are listed in the enumeration for media types determines | |
| 899 | // what files are loaded first. Variable definitions come before all other | |
| 900 | // plain text documents. | |
| 901 | final var bins = paths | |
| 902 | .stream() | |
| 903 | .collect( | |
| 904 | groupingBy( | |
| 905 | path -> bin.apply( fromFilename( path ) ), | |
| 906 | () -> new TreeMap<>( Enum::compareTo ), | |
| 907 | Collectors.toList() | |
| 908 | ) | |
| 909 | ); | |
| 910 | ||
| 911 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 912 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 913 | ||
| 914 | final var result = new LinkedList<File>(); | |
| 915 | ||
| 916 | // Ensure that the same types are listed together (keep insertion order). | |
| 917 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 918 | files.stream().map( File::new ).toList() ) | |
| 919 | ); | |
| 920 | ||
| 921 | return result; | |
| 922 | } | |
| 923 | ||
| 924 | /** | |
| 925 | * Force the active editor to update, which will cause the processor | |
| 926 | * to re-evaluate the interpolated definition map thereby updating the | |
| 927 | * preview pane. | |
| 928 | * | |
| 929 | * @param editor Contains the source document to update in the preview pane. | |
| 930 | */ | |
| 931 | private void process( final TextEditor editor ) { | |
| 932 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 933 | // text editor immediately for caret movement. The preview will have a | |
| 934 | // slight delay when catching up to the caret position. | |
| 935 | final var task = new Task<Void>() { | |
| 936 | @Override | |
| 937 | public Void call() { | |
| 938 | try { | |
| 939 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 940 | p.apply( editor == null ? "" : editor.getText() ); | |
| 941 | } catch( final Exception ex ) { | |
| 942 | clue( ex ); | |
| 943 | } | |
| 944 | ||
| 945 | return null; | |
| 946 | } | |
| 947 | }; | |
| 948 | ||
| 949 | // TODO: Each time the editor successfully runs the processor, the task is | |
| 950 | // considered successful. Due to the rapid-fire nature of processing | |
| 951 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 952 | // scroll each time. | |
| 953 | // The algorithm: | |
| 954 | // 1. Peek at the oldest time. | |
| 955 | // 2. If the difference between the oldest time and current time exceeds | |
| 956 | // 250 milliseconds, then invoke the scrolling. | |
| 957 | // 3. Insert the current time into the circular queue. | |
| 958 | task.setOnSucceeded( | |
| 959 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 960 | ); | |
| 961 | ||
| 962 | // Prevents multiple process requests from executing simultaneously (due | |
| 963 | // to having a restricted queue size). | |
| 964 | sExecutor.execute( task ); | |
| 965 | } | |
| 966 | ||
| 967 | /** | |
| 968 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 969 | * events. The tab pane is associated with a given media type so that | |
| 970 | * similar files can be grouped together. | |
| 971 | * | |
| 972 | * @param mediaType The media type to associate with the tab pane. | |
| 973 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 974 | */ | |
| 975 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 976 | for( final var pane : mTabPanes ) { | |
| 977 | for( final var tab : pane.getTabs() ) { | |
| 978 | final var node = tab.getContent(); | |
| 979 | ||
| 980 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 981 | return pane; | |
| 982 | } | |
| 983 | } | |
| 984 | } | |
| 985 | ||
| 986 | final var pane = createTabPane(); | |
| 987 | mTabPanes.add( pane ); | |
| 988 | return pane; | |
| 989 | } | |
| 990 | ||
| 991 | /** | |
| 992 | * Creates an initialized {@link TabPane} instance. | |
| 993 | * | |
| 994 | * @return A new {@link TabPane} with all listeners configured. | |
| 995 | */ | |
| 996 | private TabPane createTabPane() { | |
| 997 | final var tabPane = new DetachableTabPane(); | |
| 998 | ||
| 999 | initStageOwnerFactory( tabPane ); | |
| 1000 | initTabListener( tabPane ); | |
| 1001 | ||
| 1002 | return tabPane; | |
| 1003 | } | |
| 1004 | ||
| 1005 | /** | |
| 1006 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 1007 | * the stage owner factory must be given its parent window, which will | |
| 1008 | * own the child window. The parent window is the {@link MainPane}'s | |
| 1009 | * {@link Scene}'s {@link Window} instance. | |
| 1010 | * | |
| 1011 | * <p> | |
| 1012 | * This will derives the new title from the main window title, incrementing | |
| 1013 | * the window count to help uniquely identify the child windows. | |
| 1014 | * </p> | |
| 1015 | * | |
| 1016 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 1017 | */ | |
| 1018 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 1019 | tabPane.setStageOwnerFactory( stage -> { | |
| 1020 | final var title = get( | |
| 1021 | "Detach.tab.title", | |
| 1022 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 1023 | ); | |
| 1024 | stage.setTitle( title ); | |
| 1025 | ||
| 1026 | return getScene().getWindow(); | |
| 1027 | } ); | |
| 1028 | } | |
| 1029 | ||
| 1030 | /** | |
| 1031 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 1032 | * it is added to the given {@link DetachableTabPane} instance. | |
| 1033 | * <p> | |
| 1034 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 1035 | * is initialized to perform synchronized scrolling between the editor and | |
| 1036 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 1037 | * tabs is given focus. | |
| 1038 | * </p> | |
| 1039 | * <p> | |
| 1040 | * Note that multiple tabs can be added simultaneously. | |
| 1041 | * </p> | |
| 1042 | * | |
| 1043 | * @param tabPane A new {@link TabPane} to configure. | |
| 1044 | */ | |
| 1045 | private void initTabListener( final TabPane tabPane ) { | |
| 1046 | tabPane.getTabs().addListener( | |
| 1047 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 1048 | while( listener.next() ) { | |
| 1049 | if( listener.wasAdded() ) { | |
| 1050 | final var tabs = listener.getAddedSubList(); | |
| 1051 | ||
| 1052 | tabs.forEach( tab -> { | |
| 1053 | final var node = tab.getContent(); | |
| 1054 | ||
| 1055 | if( node instanceof TextEditor ) { | |
| 1056 | initScrollEventListener( tab ); | |
| 1057 | } | |
| 1058 | } ); | |
| 1059 | ||
| 1060 | // Select and give focus to the last tab opened. | |
| 1061 | final var index = tabs.size() - 1; | |
| 1062 | if( index >= 0 ) { | |
| 1063 | final var tab = tabs.get( index ); | |
| 1064 | tabPane.getSelectionModel().select( tab ); | |
| 1065 | tab.getContent().requestFocus(); | |
| 1066 | } | |
| 1067 | } | |
| 1068 | } | |
| 1069 | } | |
| 1070 | ); | |
| 1071 | } | |
| 1072 | ||
| 1073 | /** | |
| 1074 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 1075 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 1076 | * | |
| 1077 | * @param tab The container for an instance of {@link TextEditor}. | |
| 1078 | */ | |
| 1079 | private void initScrollEventListener( final Tab tab ) { | |
| 1080 | final var editor = (TextEditor) tab.getContent(); | |
| 1081 | final var scrollPane = editor.getScrollPane(); | |
| 1082 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 1083 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 1084 | ||
| 1085 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 1086 | } | |
| 1087 | ||
| 1088 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 1089 | final var items = getItems(); | |
| 1090 | ||
| 1091 | if( !items.contains( tabPane ) ) { | |
| 1092 | items.add( index, tabPane ); | |
| 1093 | } | |
| 1094 | } | |
| 1095 | ||
| 1096 | private void addTabPane( final TabPane tabPane ) { | |
| 1097 | addTabPane( getItems().size(), tabPane ); | |
| 1098 | } | |
| 1099 | ||
| 1100 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | |
| 1101 | final var w = getWorkspace(); | |
| 1102 | ||
| 1103 | return builder() | |
| 1104 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 1105 | .with( Mutator::setLocale, w::getLocale ) | |
| 1106 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 1107 | .with( Mutator::setThemeDir, w::getThemesPath ) | |
| 1108 | .with( Mutator::setCacheDir, | |
| 1109 | () -> w.getFile( KEY_CACHE_DIR ) ) | |
| 1110 | .with( Mutator::setImageDir, | |
| 1111 | () -> w.getFile( KEY_IMAGE_DIR ) ) | |
| 1112 | .with( Mutator::setImageOrder, | |
| 1113 | () -> w.getString( KEY_IMAGE_ORDER ) ) | |
| 1114 | .with( Mutator::setImageServer, | |
| 1115 | () -> w.getString( KEY_IMAGE_SERVER ) ) | |
| 1116 | .with( Mutator::setFontDir, | |
| 1117 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | |
| 1118 | .with( Mutator::setCaret, | |
| 1119 | () -> getTextEditor().getCaret() ) | |
| 1120 | .with( Mutator::setSigilBegan, | |
| 1121 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 1122 | .with( Mutator::setSigilEnded, | |
| 1123 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 1124 | .with( Mutator::setRScript, | |
| 1125 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 1126 | .with( Mutator::setRWorkingDir, | |
| 1127 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 1128 | .with( Mutator::setCurlQuotes, | |
| 1129 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 1130 | .with( Mutator::setAutoRemove, | |
| 1131 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 1132 | } | |
| 1133 | ||
| 1134 | public ProcessorContext createProcessorContext() { | |
| 1135 | return createProcessorContextBuilder( NONE ).build(); | |
| 1136 | } | |
| 1137 | ||
| 1138 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( | |
| 1139 | final ExportFormat format ) { | |
| 1140 | final var textEditor = getTextEditor(); | |
| 1141 | final var sourcePath = textEditor.getPath(); | |
| 1142 | ||
| 1143 | return processorContextBuilder() | |
| 1144 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1145 | .with( Mutator::setExportFormat, format ); | |
| 1146 | } | |
| 1147 | ||
| 1148 | /** | |
| 1149 | * @param targetPath Used when exporting to a PDF file (binary). | |
| 1150 | * @param format Used when processors export to a new text format. | |
| 1151 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1152 | * {@link Processor}. | |
| 1153 | */ | |
| 1154 | public ProcessorContext createProcessorContext( | |
| 1155 | final Path targetPath, final ExportFormat format ) { | |
| 1156 | assert targetPath != null; | |
| 1157 | assert format != null; | |
| 1158 | ||
| 1159 | return createProcessorContextBuilder( format ) | |
| 1160 | .with( Mutator::setTargetPath, targetPath ) | |
| 1161 | .build(); | |
| 1162 | } | |
| 1163 | ||
| 1164 | /** | |
| 1165 | * @param sourcePath Used by {@link ProcessorFactory} to determine | |
| 1166 | * {@link Processor} type to create based on file type. | |
| 1167 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1168 | * {@link Processor}. | |
| 1169 | */ | |
| 1170 | private ProcessorContext createProcessorContext( final Path sourcePath ) { | |
| 1171 | return processorContextBuilder() | |
| 1172 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1173 | .with( Mutator::setExportFormat, NONE ) | |
| 1174 | .build(); | |
| 1175 | } | |
| 1176 | ||
| 1177 | private TextResource createTextResource( final File file ) { | |
| 1178 | if( fromFilename( file ) == TEXT_YAML ) { | |
| 1179 | final var editor = createDefinitionEditor( file ); | |
| 1180 | mDefinitionEditor.set( editor ); | |
| 1181 | return editor; | |
| 1182 | } | |
| 1183 | else { | |
| 1184 | final var editor = createMarkdownEditor( file ); | |
| 1185 | mTextEditor.set( editor ); | |
| 1186 | return editor; | |
| 1187 | } | |
| 1188 | } | |
| 1189 | ||
| 1190 | /** | |
| 1191 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1192 | * caret change events and text change events. Text change events must | |
| 1193 | * take priority over caret change events because it's possible to change | |
| 1194 | * the text without moving the caret (e.g., delete selected text). | |
| 1195 | * | |
| 1196 | * @param inputFile The file containing contents for the text editor. | |
| 1197 | * @return A non-null text editor. | |
| 1198 | */ | |
| 1199 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { | |
| 1200 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1201 | ||
| 1202 | // Listener for editor modifications or caret position changes. | |
| 1203 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 1204 | if( n ) { | |
| 1205 | // Reset the status bar after changing the text. | |
| 1206 | clue(); | |
| 1207 | ||
| 1208 | // Processing the text may update the status bar. | |
| 1209 | process( editor ); | |
| 1210 | ||
| 1211 | // Update the caret position in the status bar. | |
| 1212 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1213 | } | |
| 1214 | } ); | |
| 1215 | ||
| 1216 | editor.addEventListener( | |
| 1217 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1218 | ); | |
| 1219 | ||
| 1220 | editor.addEventListener( | |
| 1221 | keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor ) | |
| 1222 | ); | |
| 1223 | ||
| 1224 | final var textArea = editor.getTextArea(); | |
| 1225 | ||
| 1226 | // Spell check when the paragraph changes. | |
| 1227 | textArea | |
| 1228 | .plainTextChanges() | |
| 1229 | .filter( p -> !p.isIdentity() ) | |
| 1230 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | |
| 1231 | ||
| 1232 | // Store the caret position to restore it after restarting the application. | |
| 1233 | textArea.caretPositionProperty().addListener( | |
| 1234 | ( c, o, n ) -> | |
| 1235 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | |
| 1236 | ); | |
| 1237 | ||
| 1238 | // Check the entire document after the spellchecker is initialized (with | |
| 1239 | // a valid lexicon) so that only the current paragraph need be scanned | |
| 1240 | // while editing. (Technically, only the most recently modified word must | |
| 1241 | // be scanned.) | |
| 1242 | mSpellChecker.addListener( | |
| 1243 | ( c, o, n ) -> runLater( | |
| 1244 | () -> iterateEditors( mEditorSpeller::checkDocument ) | |
| 1245 | ) | |
| 1246 | ); | |
| 1247 | ||
| 1248 | // Check the entire document after it has been loaded. | |
| 1249 | mEditorSpeller.checkDocument( editor ); | |
| 1250 | ||
| 1251 | return editor; | |
| 1252 | } | |
| 1253 | ||
| 1254 | /** | |
| 1255 | * Creates a processor for an editor, provided one doesn't already exist. | |
| 1256 | * | |
| 1257 | * @param editor The editor that potentially requires an associated processor. | |
| 1258 | */ | |
| 1259 | private void updateProcessors( final TextEditor editor ) { | |
| 1260 | final var path = editor.getFile().toPath(); | |
| 1261 | ||
| 1262 | mProcessors.computeIfAbsent( | |
| 1263 | editor, p -> createProcessors( | |
| 1264 | createProcessorContext( path ), | |
| 1265 | createHtmlPreviewProcessor() | |
| 1266 | ) | |
| 1267 | ); | |
| 1268 | } | |
| 1269 | ||
| 1270 | /** | |
| 1271 | * Removes a processor for an editor. This is required because a file may | |
| 1272 | * change type while editing (e.g., from plain Markdown to R Markdown). | |
| 1273 | * In the case that an editor's type changes, its associated processor must | |
| 1274 | * be changed accordingly. | |
| 1275 | * | |
| 1276 | * @param editor The editor that potentially requires an associated processor. | |
| 1277 | */ | |
| 1278 | private void removeProcessor( final TextEditor editor ) { | |
| 1279 | mProcessors.remove( editor ); | |
| 1280 | } | |
| 1281 | ||
| 1282 | /** | |
| 1283 | * Creates a {@link Processor} capable of rendering an HTML document onto | |
| 1284 | * a GUI widget. | |
| 1285 | * | |
| 1286 | * @return The {@link Processor} for rendering an HTML document. | |
| 1287 | */ | |
| 1288 | private Processor<String> createHtmlPreviewProcessor() { | |
| 1289 | return new HtmlPreviewProcessor( getPreview() ); | |
| 1290 | } | |
| 1291 | ||
| 1292 | /** | |
| 1293 | * Creates a spellchecker that accepts all words as correct. This allows | |
| 1294 | * the spellchecker property to be initialized to a known valid value. | |
| 1295 | * | |
| 1296 | * @return A wrapped {@link PermissiveSpeller}. | |
| 1297 | */ | |
| 1298 | private ObjectProperty<SpellChecker> createSpellChecker() { | |
| 1299 | return new SimpleObjectProperty<>( new PermissiveSpeller() ); | |
| 1300 | } | |
| 1301 | ||
| 1302 | private TextEditorSpellChecker createTextEditorSpellChecker( | |
| 1303 | final ObjectProperty<SpellChecker> spellChecker ) { | |
| 1304 | return new TextEditorSpellChecker( spellChecker ); | |
| 1305 | } | |
| 1306 | ||
| 1307 | /** | |
| 1308 | * Delegates to {@link #autoinsert()}. | |
| 1309 | * | |
| 1310 | * @param keyEvent Ignored. | |
| 1311 | */ | |
| 1312 | private void autoinsert( final KeyEvent keyEvent ) { | |
| 203 | mTextEditor.addListener( ( _, o, n ) -> { | |
| 204 | if( o != null ) { | |
| 205 | removeProcessor( o ); | |
| 206 | } | |
| 207 | ||
| 208 | if( n != null ) { | |
| 209 | mPreview.setBaseUri( n.getPath() ); | |
| 210 | updateProcessors( n ); | |
| 211 | process( n ); | |
| 212 | } | |
| 213 | } ); | |
| 214 | ||
| 215 | mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); | |
| 216 | mDefinitionEditor.set( createDefinitionEditor( workspace ) ); | |
| 217 | mVariableNameInjector = new VariableNameInjector( workspace ); | |
| 218 | mRBootstrapController = new RBootstrapController( | |
| 219 | workspace, mDefinitionEditor.get()::getDefinitions | |
| 220 | ); | |
| 221 | ||
| 222 | // If the user modifies the definitions, re-process the variables. | |
| 223 | mDefinitionEditor.addListener( ( _, _, _ ) -> { | |
| 224 | final var textEditor = getTextEditor(); | |
| 225 | ||
| 226 | if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) { | |
| 227 | mRBootstrapController.update(); | |
| 228 | } | |
| 229 | ||
| 230 | process( textEditor ); | |
| 231 | } ); | |
| 232 | ||
| 233 | open( collect( getRecentFiles() ) ); | |
| 234 | viewPreview(); | |
| 235 | setDividerPositions( calculateDividerPositions() ); | |
| 236 | ||
| 237 | // Once the main scene's window regains focus, update the active definition | |
| 238 | // editor to the currently selected tab. | |
| 239 | runLater( () -> getWindow().setOnCloseRequest( event -> { | |
| 240 | // Order matters: Open file names must be persisted before closing all. | |
| 241 | mWorkspace.save(); | |
| 242 | ||
| 243 | if( closeAll() ) { | |
| 244 | exit(); | |
| 245 | terminate( 0 ); | |
| 246 | } | |
| 247 | ||
| 248 | event.consume(); | |
| 249 | } ) ); | |
| 250 | ||
| 251 | register( this ); | |
| 252 | initAutosave( workspace ); | |
| 253 | ||
| 254 | restoreSession(); | |
| 255 | runLater( this::restoreFocus ); | |
| 256 | ||
| 257 | mInstallWizard = new TypesetterInstaller( workspace ); | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Called when spellchecking can be run. This will reload the dictionary | |
| 262 | * into memory once, and then re-use it for all the existing text editors. | |
| 263 | * | |
| 264 | * @param event The event to process, having a populated word-frequency map. | |
| 265 | */ | |
| 266 | @Subscribe | |
| 267 | public void handle( final LexiconLoadedEvent event ) { | |
| 268 | final var lexicon = event.getLexicon(); | |
| 269 | ||
| 270 | try { | |
| 271 | final var checker = SymSpellSpeller.forLexicon( lexicon ); | |
| 272 | mSpellChecker.set( checker ); | |
| 273 | } catch( final Exception ex ) { | |
| 274 | clue( ex ); | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | @Subscribe | |
| 279 | public void handle( final TextEditorFocusEvent event ) { | |
| 280 | mTextEditor.set( event.get() ); | |
| 281 | } | |
| 282 | ||
| 283 | @Subscribe | |
| 284 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 285 | mDefinitionEditor.set( event.get() ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Typically called when a file name is clicked in the preview panel. | |
| 290 | * | |
| 291 | * @param event The event to process, must contain a valid file reference. | |
| 292 | */ | |
| 293 | @Subscribe | |
| 294 | public void handle( final FileOpenEvent event ) { | |
| 295 | final File eventFile; | |
| 296 | final var eventUri = event.getUri(); | |
| 297 | ||
| 298 | if( eventUri.isAbsolute() ) { | |
| 299 | eventFile = new File( eventUri.getPath() ); | |
| 300 | } | |
| 301 | else { | |
| 302 | final var activeFile = getTextEditor().getFile(); | |
| 303 | final var parent = activeFile.getParentFile(); | |
| 304 | ||
| 305 | if( parent == null ) { | |
| 306 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 307 | return; | |
| 308 | } | |
| 309 | else { | |
| 310 | final var parentPath = parent.getAbsolutePath(); | |
| 311 | eventFile = toFile( Path.of( parentPath, eventUri.getPath() ) ); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | final var mediaType = MediaTypeExtension.fromFile( eventFile ); | |
| 316 | ||
| 317 | runLater( () -> { | |
| 318 | // Open text files locally. | |
| 319 | if( mediaType.isType( TEXT ) ) { | |
| 320 | open( eventFile ); | |
| 321 | } | |
| 322 | else { | |
| 323 | try { | |
| 324 | // Delegate opening all other file types to the operating system. | |
| 325 | getDesktop().open( eventFile ); | |
| 326 | } catch( final Exception ex ) { | |
| 327 | clue( ex ); | |
| 328 | } | |
| 329 | } | |
| 330 | } ); | |
| 331 | } | |
| 332 | ||
| 333 | @Subscribe | |
| 334 | public void handle( final CaretNavigationEvent event ) { | |
| 335 | runLater( () -> { | |
| 336 | final var textArea = getTextEditor(); | |
| 337 | textArea.moveTo( event.getOffset() ); | |
| 338 | textArea.requestFocus(); | |
| 339 | } ); | |
| 340 | } | |
| 341 | ||
| 342 | @Subscribe | |
| 343 | public void handle( final InsertDefinitionEvent<String> event ) { | |
| 344 | final var leaf = event.getLeaf(); | |
| 345 | final var editor = mTextEditor.get(); | |
| 346 | ||
| 347 | mVariableNameInjector.insert( editor, leaf ); | |
| 348 | } | |
| 349 | ||
| 350 | private void initAutosave( final Workspace workspace ) { | |
| 351 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 352 | ||
| 353 | rate.addListener( | |
| 354 | ( _, _, _ ) -> { | |
| 355 | final var taskRef = mSaveTask.get(); | |
| 356 | ||
| 357 | // Prevent multiple auto-saves from running. | |
| 358 | if( taskRef != null ) { | |
| 359 | taskRef.cancel( false ); | |
| 360 | } | |
| 361 | ||
| 362 | initAutosave( rate ); | |
| 363 | } | |
| 364 | ); | |
| 365 | ||
| 366 | // Start the save listener (avoids duplicating some code). | |
| 367 | initAutosave( rate ); | |
| 368 | } | |
| 369 | ||
| 370 | private void initAutosave( final IntegerProperty rate ) { | |
| 371 | mSaveTask.set( | |
| 372 | mSaver.scheduleAtFixedRate( | |
| 373 | () -> { | |
| 374 | if( getTextEditor().isModified() ) { | |
| 375 | // Ensure the modified indicator is cleared by running on EDT. | |
| 376 | runLater( this::save ); | |
| 377 | } | |
| 378 | }, 0, rate.intValue(), SECONDS | |
| 379 | ) | |
| 380 | ); | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * TODO: Load divider positions from exported settings, see | |
| 385 | * {@link #collect(SetProperty)} comment. | |
| 386 | */ | |
| 387 | private double[] calculateDividerPositions() { | |
| 388 | final var ratio = 100f / getItems().size() / 100; | |
| 389 | final var positions = getDividerPositions(); | |
| 390 | ||
| 391 | for( int i = 0; i < positions.length; i++ ) { | |
| 392 | positions[ i ] = ratio * i; | |
| 393 | } | |
| 394 | ||
| 395 | return positions; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * Opens all the files into the application, provided the paths are unique. | |
| 400 | * This may only be called for any type of files that a user can edit | |
| 401 | * (i.e., update and persist), such as definitions and text files. | |
| 402 | * | |
| 403 | * @param files The list of files to open. | |
| 404 | */ | |
| 405 | public void open( final List<File> files ) { | |
| 406 | files.forEach( this::open ); | |
| 407 | } | |
| 408 | ||
| 409 | /** | |
| 410 | * This opens the given file. Since the preview pane is not a file that | |
| 411 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 412 | * This will exit early if the given file is not a regular file (i.e., a | |
| 413 | * directory). | |
| 414 | * | |
| 415 | * @param inputFile The file to open. | |
| 416 | */ | |
| 417 | private void open( final File inputFile ) { | |
| 418 | // Prevent opening directories (a non-existent "untitled.md" is fine). | |
| 419 | if( !inputFile.isFile() && inputFile.exists() ) { | |
| 420 | return; | |
| 421 | } | |
| 422 | ||
| 423 | final var mediaType = fromFilename( inputFile ); | |
| 424 | ||
| 425 | // Only allow opening text files. | |
| 426 | if( !mediaType.isType( TEXT ) ) { | |
| 427 | return; | |
| 428 | } | |
| 429 | ||
| 430 | final var tab = createTab( inputFile ); | |
| 431 | final var node = tab.getContent(); | |
| 432 | final var tabPane = obtainTabPane( mediaType ); | |
| 433 | ||
| 434 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 435 | tabPane.setFocusTraversable( false ); | |
| 436 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 437 | tabPane.getTabs().add( tab ); | |
| 438 | ||
| 439 | // Attach the tab scene factory for new tab panes. | |
| 440 | if( !getItems().contains( tabPane ) ) { | |
| 441 | addTabPane( | |
| 442 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 443 | ); | |
| 444 | } | |
| 445 | ||
| 446 | if( inputFile.isFile() ) { | |
| 447 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 448 | } | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Gives focus to the most recently edited document and attempts to move | |
| 453 | * the caret to the most recently known offset into said document. | |
| 454 | */ | |
| 455 | private void restoreSession() { | |
| 456 | final var workspace = getWorkspace(); | |
| 457 | final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); | |
| 458 | final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); | |
| 459 | ||
| 460 | for( final var pane : mTabPanes ) { | |
| 461 | for( final var tab : pane.getTabs() ) { | |
| 462 | final var tooltip = tab.getTooltip(); | |
| 463 | ||
| 464 | if( tooltip != null ) { | |
| 465 | final var tabName = tooltip.getText(); | |
| 466 | final var fileName = file.get().toString(); | |
| 467 | ||
| 468 | if( tabName.equalsIgnoreCase( fileName ) ) { | |
| 469 | final var node = tab.getContent(); | |
| 470 | ||
| 471 | pane.getSelectionModel().select( tab ); | |
| 472 | node.requestFocus(); | |
| 473 | ||
| 474 | if( node instanceof TextEditor editor ) { | |
| 475 | runLater( () -> editor.moveTo( offset.getValue() ) ); | |
| 476 | } | |
| 477 | ||
| 478 | break; | |
| 479 | } | |
| 480 | } | |
| 481 | } | |
| 482 | } | |
| 483 | } | |
| 484 | ||
| 485 | /** | |
| 486 | * Sets the focus to the middle pane, which contains the text editor tabs. | |
| 487 | */ | |
| 488 | private void restoreFocus() { | |
| 489 | // Work around a bug where focusing directly on the middle pane results | |
| 490 | // in the R engine not loading variables properly. | |
| 491 | mTabPanes.get( 0 ).requestFocus(); | |
| 492 | ||
| 493 | // This is the only line that should be required. | |
| 494 | mTabPanes.get( 1 ).requestFocus(); | |
| 495 | } | |
| 496 | ||
| 497 | /** | |
| 498 | * Opens a new text editor document using a document file name that doesn't | |
| 499 | * clash with an existing document. | |
| 500 | */ | |
| 501 | public void newTextEditor() { | |
| 502 | final String key = "file.default.document."; | |
| 503 | final String prefix = Constants.get( STR."\{key}prefix" ); | |
| 504 | final String suffix = Constants.get( STR."\{key}suffix" ); | |
| 505 | ||
| 506 | File file = new File( STR."\{prefix}.\{suffix}" ); | |
| 507 | int i = 0; | |
| 508 | ||
| 509 | while( file.exists() && i++ < 100 ) { | |
| 510 | file = new File( STR."\{prefix}-\{i}.\{suffix}" ); | |
| 511 | } | |
| 512 | ||
| 513 | open( file ); | |
| 514 | } | |
| 515 | ||
| 516 | /** | |
| 517 | * Opens a new definition editor document using the default definition | |
| 518 | * file name. | |
| 519 | */ | |
| 520 | @SuppressWarnings( "unused" ) | |
| 521 | public void newDefinitionEditor() { | |
| 522 | open( DEFINITION_DEFAULT ); | |
| 523 | } | |
| 524 | ||
| 525 | /** | |
| 526 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 527 | * that they save themselves. | |
| 528 | */ | |
| 529 | public void saveAll() { | |
| 530 | iterateEditors( this::save ); | |
| 531 | } | |
| 532 | ||
| 533 | /** | |
| 534 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 535 | * checking if modified first because if the user swaps external media from | |
| 536 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 537 | * the user: save always re-saves. Also, it's less code. | |
| 538 | */ | |
| 539 | public void save() { | |
| 540 | save( getTextEditor() ); | |
| 541 | } | |
| 542 | ||
| 543 | /** | |
| 544 | * Saves the active {@link TextEditor} under a new name. | |
| 545 | * | |
| 546 | * @param files The new active editor {@link File} reference, must contain | |
| 547 | * at least one element. | |
| 548 | */ | |
| 549 | public void saveAs( final List<File> files ) { | |
| 550 | assert files != null; | |
| 551 | assert !files.isEmpty(); | |
| 552 | final var editor = getTextEditor(); | |
| 553 | final var tab = getTab( editor ); | |
| 554 | final var file = files.getFirst(); | |
| 555 | ||
| 556 | // If the file type has changed, refresh the processors. | |
| 557 | final var mediaType = fromFilename( file ); | |
| 558 | final var typeChanged = !editor.isMediaType( mediaType ); | |
| 559 | ||
| 560 | if( typeChanged ) { | |
| 561 | removeProcessor( editor ); | |
| 562 | } | |
| 563 | ||
| 564 | editor.rename( file ); | |
| 565 | tab.ifPresent( t -> { | |
| 566 | t.setText( editor.getFilename() ); | |
| 567 | t.setTooltip( createTooltip( file ) ); | |
| 568 | } ); | |
| 569 | ||
| 570 | if( typeChanged ) { | |
| 571 | updateProcessors( editor ); | |
| 572 | process( editor ); | |
| 573 | } | |
| 574 | ||
| 575 | save(); | |
| 576 | } | |
| 577 | ||
| 578 | /** | |
| 579 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 580 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 581 | * | |
| 582 | * @param resource The resource to export. | |
| 583 | */ | |
| 584 | private void save( final TextResource resource ) { | |
| 585 | try { | |
| 586 | resource.save(); | |
| 587 | } catch( final Exception ex ) { | |
| 588 | clue( ex ); | |
| 589 | sNotifier.alert( | |
| 590 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 591 | ); | |
| 592 | } | |
| 593 | } | |
| 594 | ||
| 595 | /** | |
| 596 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 597 | * | |
| 598 | * @return {@code true} when all editors, modified or otherwise, were | |
| 599 | * permitted to close; {@code false} when one or more editors were modified | |
| 600 | * and the user requested no closing. | |
| 601 | */ | |
| 602 | public boolean closeAll() { | |
| 603 | var closable = true; | |
| 604 | ||
| 605 | for( final var tabPane : mTabPanes ) { | |
| 606 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 607 | ||
| 608 | while( tabIterator.hasNext() ) { | |
| 609 | final var tab = tabIterator.next(); | |
| 610 | final var resource = tab.getContent(); | |
| 611 | ||
| 612 | // The definition panes auto-save, so being specific here prevents | |
| 613 | // closing the definitions in the situation where the user wants to | |
| 614 | // continue editing (i.e., possibly save unsaved work). | |
| 615 | if( !(resource instanceof TextEditor) ) { | |
| 616 | continue; | |
| 617 | } | |
| 618 | ||
| 619 | if( canClose( (TextEditor) resource ) ) { | |
| 620 | tabIterator.remove(); | |
| 621 | close( tab ); | |
| 622 | } | |
| 623 | else { | |
| 624 | closable = false; | |
| 625 | } | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | return closable; | |
| 630 | } | |
| 631 | ||
| 632 | /** | |
| 633 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 634 | * event. | |
| 635 | * | |
| 636 | * @param tab The {@link Tab} that was closed. | |
| 637 | */ | |
| 638 | private void close( final Tab tab ) { | |
| 639 | assert tab != null; | |
| 640 | ||
| 641 | final var handler = tab.getOnClosed(); | |
| 642 | ||
| 643 | if( handler != null ) { | |
| 644 | handler.handle( new ActionEvent() ); | |
| 645 | } | |
| 646 | } | |
| 647 | ||
| 648 | /** | |
| 649 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 650 | */ | |
| 651 | public void close() { | |
| 652 | final var editor = getTextEditor(); | |
| 653 | ||
| 654 | if( canClose( editor ) ) { | |
| 655 | close( editor ); | |
| 656 | removeProcessor( editor ); | |
| 657 | } | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Closes the given {@link TextResource}. This must not be called from within | |
| 662 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 663 | * concurrent modification exception be thrown. | |
| 664 | * | |
| 665 | * @param resource The {@link TextResource} to close, without confirming with | |
| 666 | * the user. | |
| 667 | */ | |
| 668 | private void close( final TextResource resource ) { | |
| 669 | getTab( resource ).ifPresent( | |
| 670 | tab -> { | |
| 671 | close( tab ); | |
| 672 | tab.getTabPane().getTabs().remove( tab ); | |
| 673 | } | |
| 674 | ); | |
| 675 | } | |
| 676 | ||
| 677 | /** | |
| 678 | * Answers whether the given {@link TextResource} may be closed. | |
| 679 | * | |
| 680 | * @param editor The {@link TextResource} to try closing. | |
| 681 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 682 | * the user has requested to keep the editor open. | |
| 683 | */ | |
| 684 | private boolean canClose( final TextResource editor ) { | |
| 685 | final var editorTab = getTab( editor ); | |
| 686 | final var canClose = new AtomicBoolean( true ); | |
| 687 | ||
| 688 | if( editor.isModified() ) { | |
| 689 | final var filename = new StringBuilder(); | |
| 690 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | |
| 691 | ||
| 692 | final var message = sNotifier.createNotification( | |
| 693 | Messages.get( "Alert.file.close.title" ), | |
| 694 | Messages.get( "Alert.file.close.text" ), | |
| 695 | filename.toString() | |
| 696 | ); | |
| 697 | ||
| 698 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 699 | ||
| 700 | dialog.showAndWait().ifPresent( | |
| 701 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 702 | ); | |
| 703 | } | |
| 704 | ||
| 705 | return canClose.get(); | |
| 706 | } | |
| 707 | ||
| 708 | private void iterateEditors( final Consumer<TextEditor> consumer ) { | |
| 709 | mTabPanes.forEach( | |
| 710 | tp -> tp.getTabs().forEach( tab -> { | |
| 711 | final var node = tab.getContent(); | |
| 712 | ||
| 713 | if( node instanceof final TextEditor editor ) { | |
| 714 | consumer.accept( editor ); | |
| 715 | } | |
| 716 | } ) | |
| 717 | ); | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 722 | */ | |
| 723 | public void viewPreview() { | |
| 724 | addTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 725 | } | |
| 726 | ||
| 727 | /** | |
| 728 | * Adds the document outline tab to its own, singular tab pane. | |
| 729 | */ | |
| 730 | public void viewOutline() { | |
| 731 | addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 732 | } | |
| 733 | ||
| 734 | public void viewStatistics() { | |
| 735 | addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 736 | } | |
| 737 | ||
| 738 | public void viewFiles() { | |
| 739 | try { | |
| 740 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 741 | final var fileManager = factory.createModeless(); | |
| 742 | addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 743 | } catch( final Exception ex ) { | |
| 744 | clue( ex ); | |
| 745 | } | |
| 746 | } | |
| 747 | ||
| 748 | public void viewRefresh() { | |
| 749 | mPreview.refresh(); | |
| 750 | Engine.clear(); | |
| 751 | mRBootstrapController.update(); | |
| 752 | } | |
| 753 | ||
| 754 | private void addTab( | |
| 755 | final Node node, final MediaType mediaType, final String key ) { | |
| 756 | final var tabPane = obtainTabPane( mediaType ); | |
| 757 | ||
| 758 | for( final var tab : tabPane.getTabs() ) { | |
| 759 | if( tab.getContent() == node ) { | |
| 760 | return; | |
| 761 | } | |
| 762 | } | |
| 763 | ||
| 764 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 765 | addTabPane( tabPane ); | |
| 766 | } | |
| 767 | ||
| 768 | /** | |
| 769 | * Returns the tab that contains the given {@link TextEditor}. | |
| 770 | * | |
| 771 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 772 | * @return The first tab having content that matches the given tab. | |
| 773 | */ | |
| 774 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 775 | return mTabPanes.stream() | |
| 776 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 777 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 778 | .findFirst(); | |
| 779 | } | |
| 780 | ||
| 781 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 782 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 783 | ||
| 784 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 785 | ||
| 786 | return editor; | |
| 787 | } | |
| 788 | ||
| 789 | /** | |
| 790 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 791 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 792 | * Upon changing, the variables are interpolated and the active text editor | |
| 793 | * is refreshed. | |
| 794 | * | |
| 795 | * @param workspace Has the most recently edited definitions file name. | |
| 796 | * @return A newly configured property that represents the active | |
| 797 | * {@link DefinitionEditor}, never {@code null}. | |
| 798 | */ | |
| 799 | private TextDefinition createDefinitionEditor( | |
| 800 | final Workspace workspace ) { | |
| 801 | final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION ); | |
| 802 | final var filename = fileProperty.get(); | |
| 803 | final SetProperty<String> recent = workspace.setsProperty( | |
| 804 | KEY_UI_RECENT_OPEN_PATH | |
| 805 | ); | |
| 806 | ||
| 807 | // Open the most recently used YAML definition file. | |
| 808 | for( final var recentFile : recent.get() ) { | |
| 809 | if( recentFile.endsWith( filename.toString() ) ) { | |
| 810 | return createDefinitionEditor( new File( recentFile ) ); | |
| 811 | } | |
| 812 | } | |
| 813 | ||
| 814 | return createDefaultDefinitionEditor(); | |
| 815 | } | |
| 816 | ||
| 817 | private TextDefinition createDefaultDefinitionEditor() { | |
| 818 | final var transformer = createTreeTransformer(); | |
| 819 | return new DefinitionEditor( transformer ); | |
| 820 | } | |
| 821 | ||
| 822 | private TreeTransformer createTreeTransformer() { | |
| 823 | return new YamlTreeTransformer(); | |
| 824 | } | |
| 825 | ||
| 826 | private Tab createTab( final String filename, final Node node ) { | |
| 827 | return new DetachableTab( filename, node ); | |
| 828 | } | |
| 829 | ||
| 830 | private Tab createTab( final File file ) { | |
| 831 | final var r = createTextResource( file ); | |
| 832 | final var filename = r.getFilename(); | |
| 833 | final var tab = createTab( filename, r.getNode() ); | |
| 834 | ||
| 835 | r.modifiedProperty().addListener( | |
| 836 | ( _, _, n ) -> tab.setText( filename + (n ? "*" : "") ) | |
| 837 | ); | |
| 838 | ||
| 839 | // This is called when either the tab is closed by the user clicking on | |
| 840 | // the tab's close icon or when closing (all) from the file menu. | |
| 841 | tab.setOnClosed( | |
| 842 | _ -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 843 | ); | |
| 844 | ||
| 845 | // When closing a tab, give focus to the newly revealed tab. | |
| 846 | tab.selectedProperty().addListener( ( _, _, n ) -> { | |
| 847 | if( n != null && n ) { | |
| 848 | final var pane = tab.getTabPane(); | |
| 849 | ||
| 850 | if( pane != null ) { | |
| 851 | pane.requestFocus(); | |
| 852 | } | |
| 853 | } | |
| 854 | } ); | |
| 855 | ||
| 856 | tab.tabPaneProperty().addListener( ( _, _, nPane ) -> { | |
| 857 | if( nPane != null ) { | |
| 858 | nPane.focusedProperty().addListener( ( _, _, n ) -> { | |
| 859 | if( n != null && n ) { | |
| 860 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 861 | final var node = selected.getContent(); | |
| 862 | node.requestFocus(); | |
| 863 | } | |
| 864 | } ); | |
| 865 | } | |
| 866 | } ); | |
| 867 | ||
| 868 | return tab; | |
| 869 | } | |
| 870 | ||
| 871 | /** | |
| 872 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 873 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 874 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 875 | * be replaced by such a class. | |
| 876 | * <p> | |
| 877 | * When binning the files, this makes sure that at least one file exists | |
| 878 | * for every type. If the user has opted to close a particular type (such | |
| 879 | * as the definition pane), the view will suppressed elsewhere. | |
| 880 | * </p> | |
| 881 | * <p> | |
| 882 | * The order that the binned files are returned will be reflected in the | |
| 883 | * order that the corresponding panes are rendered in the UI. | |
| 884 | * </p> | |
| 885 | * | |
| 886 | * @param paths The file paths to bin according to their type. | |
| 887 | * @return An in-order list of files, first by structured definition files, | |
| 888 | * then by plain text documents. | |
| 889 | */ | |
| 890 | private List<File> collect( final SetProperty<String> paths ) { | |
| 891 | // Treat all files destined for the text editor as plain text documents | |
| 892 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 893 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 894 | final Function<MediaType, MediaType> bin = | |
| 895 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 896 | ||
| 897 | // Create two groups: YAML files and plain text files. The order that | |
| 898 | // the elements are listed in the enumeration for media types determines | |
| 899 | // what files are loaded first. Variable definitions come before all other | |
| 900 | // plain text documents. | |
| 901 | final var bins = paths | |
| 902 | .stream() | |
| 903 | .collect( | |
| 904 | groupingBy( | |
| 905 | path -> bin.apply( fromFilename( path ) ), | |
| 906 | () -> new TreeMap<>( Enum::compareTo ), | |
| 907 | Collectors.toList() | |
| 908 | ) | |
| 909 | ); | |
| 910 | ||
| 911 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 912 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 913 | ||
| 914 | final var result = new LinkedList<File>(); | |
| 915 | ||
| 916 | // Ensure that the same types are listed together (keep insertion order). | |
| 917 | bins.forEach( ( _, files ) -> result.addAll( | |
| 918 | files.stream().map( File::new ).toList() ) | |
| 919 | ); | |
| 920 | ||
| 921 | return result; | |
| 922 | } | |
| 923 | ||
| 924 | /** | |
| 925 | * Force the active editor to update, which will cause the processor | |
| 926 | * to re-evaluate the interpolated definition map thereby updating the | |
| 927 | * preview pane. | |
| 928 | * | |
| 929 | * @param editor Contains the source document to update in the preview pane. | |
| 930 | */ | |
| 931 | private void process( final TextEditor editor ) { | |
| 932 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 933 | // text editor immediately for caret movement. The preview will have a | |
| 934 | // slight delay when catching up to the caret position. | |
| 935 | final var task = new Task<Void>() { | |
| 936 | @Override | |
| 937 | public Void call() { | |
| 938 | try { | |
| 939 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 940 | p.apply( editor == null ? "" : editor.getText() ); | |
| 941 | } catch( final Exception ex ) { | |
| 942 | clue( ex ); | |
| 943 | } | |
| 944 | ||
| 945 | return null; | |
| 946 | } | |
| 947 | }; | |
| 948 | ||
| 949 | // TODO: Each time the editor successfully runs the processor, the task is | |
| 950 | // considered successful. Due to the rapid-fire nature of processing | |
| 951 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 952 | // scroll each time. | |
| 953 | // The algorithm: | |
| 954 | // 1. Peek at the oldest time. | |
| 955 | // 2. If the difference between the oldest time and current time exceeds | |
| 956 | // 250 milliseconds, then invoke the scrolling. | |
| 957 | // 3. Insert the current time into the circular queue. | |
| 958 | task.setOnSucceeded( | |
| 959 | _ -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 960 | ); | |
| 961 | ||
| 962 | // Prevents multiple process requests from executing simultaneously (due | |
| 963 | // to having a restricted queue size). | |
| 964 | sExecutor.execute( task ); | |
| 965 | } | |
| 966 | ||
| 967 | /** | |
| 968 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 969 | * events. The tab pane is associated with a given media type so that | |
| 970 | * similar files can be grouped together. | |
| 971 | * | |
| 972 | * @param mediaType The media type to associate with the tab pane. | |
| 973 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 974 | */ | |
| 975 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 976 | for( final var pane : mTabPanes ) { | |
| 977 | for( final var tab : pane.getTabs() ) { | |
| 978 | final var node = tab.getContent(); | |
| 979 | ||
| 980 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 981 | return pane; | |
| 982 | } | |
| 983 | } | |
| 984 | } | |
| 985 | ||
| 986 | final var pane = createTabPane(); | |
| 987 | mTabPanes.add( pane ); | |
| 988 | return pane; | |
| 989 | } | |
| 990 | ||
| 991 | /** | |
| 992 | * Creates an initialized {@link TabPane} instance. | |
| 993 | * | |
| 994 | * @return A new {@link TabPane} with all listeners configured. | |
| 995 | */ | |
| 996 | private TabPane createTabPane() { | |
| 997 | final var tabPane = new DetachableTabPane(); | |
| 998 | ||
| 999 | initStageOwnerFactory( tabPane ); | |
| 1000 | initTabListener( tabPane ); | |
| 1001 | ||
| 1002 | return tabPane; | |
| 1003 | } | |
| 1004 | ||
| 1005 | /** | |
| 1006 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 1007 | * the stage owner factory must be given its parent window, which will | |
| 1008 | * own the child window. The parent window is the {@link MainPane}'s | |
| 1009 | * {@link Scene}'s {@link Window} instance. | |
| 1010 | * | |
| 1011 | * <p> | |
| 1012 | * This will derives the new title from the main window title, incrementing | |
| 1013 | * the window count to help uniquely identify the child windows. | |
| 1014 | * </p> | |
| 1015 | * | |
| 1016 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 1017 | */ | |
| 1018 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 1019 | tabPane.setStageOwnerFactory( stage -> { | |
| 1020 | final var title = get( | |
| 1021 | "Detach.tab.title", | |
| 1022 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 1023 | ); | |
| 1024 | stage.setTitle( title ); | |
| 1025 | ||
| 1026 | return getScene().getWindow(); | |
| 1027 | } ); | |
| 1028 | } | |
| 1029 | ||
| 1030 | /** | |
| 1031 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 1032 | * it is added to the given {@link DetachableTabPane} instance. | |
| 1033 | * <p> | |
| 1034 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 1035 | * is initialized to perform synchronized scrolling between the editor and | |
| 1036 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 1037 | * tabs is given focus. | |
| 1038 | * </p> | |
| 1039 | * <p> | |
| 1040 | * Note that multiple tabs can be added simultaneously. | |
| 1041 | * </p> | |
| 1042 | * | |
| 1043 | * @param tabPane A new {@link TabPane} to configure. | |
| 1044 | */ | |
| 1045 | private void initTabListener( final TabPane tabPane ) { | |
| 1046 | tabPane.getTabs().addListener( | |
| 1047 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 1048 | while( listener.next() ) { | |
| 1049 | if( listener.wasAdded() ) { | |
| 1050 | final var tabs = listener.getAddedSubList(); | |
| 1051 | ||
| 1052 | tabs.forEach( tab -> { | |
| 1053 | final var node = tab.getContent(); | |
| 1054 | ||
| 1055 | if( node instanceof TextEditor ) { | |
| 1056 | initScrollEventListener( tab ); | |
| 1057 | } | |
| 1058 | } ); | |
| 1059 | ||
| 1060 | // Select and give focus to the last tab opened. | |
| 1061 | final var index = tabs.size() - 1; | |
| 1062 | if( index >= 0 ) { | |
| 1063 | final var tab = tabs.get( index ); | |
| 1064 | tabPane.getSelectionModel().select( tab ); | |
| 1065 | tab.getContent().requestFocus(); | |
| 1066 | } | |
| 1067 | } | |
| 1068 | } | |
| 1069 | } | |
| 1070 | ); | |
| 1071 | } | |
| 1072 | ||
| 1073 | /** | |
| 1074 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 1075 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 1076 | * | |
| 1077 | * @param tab The container for an instance of {@link TextEditor}. | |
| 1078 | */ | |
| 1079 | private void initScrollEventListener( final Tab tab ) { | |
| 1080 | final var editor = (TextEditor) tab.getContent(); | |
| 1081 | final var scrollPane = editor.getScrollPane(); | |
| 1082 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 1083 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 1084 | ||
| 1085 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 1086 | } | |
| 1087 | ||
| 1088 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 1089 | final var items = getItems(); | |
| 1090 | ||
| 1091 | if( !items.contains( tabPane ) ) { | |
| 1092 | items.add( index, tabPane ); | |
| 1093 | } | |
| 1094 | } | |
| 1095 | ||
| 1096 | private void addTabPane( final TabPane tabPane ) { | |
| 1097 | addTabPane( getItems().size(), tabPane ); | |
| 1098 | } | |
| 1099 | ||
| 1100 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | |
| 1101 | final var w = getWorkspace(); | |
| 1102 | ||
| 1103 | return builder() | |
| 1104 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 1105 | .with( Mutator::setLocale, w::getLocale ) | |
| 1106 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 1107 | .with( Mutator::setThemeDir, w::getThemesPath ) | |
| 1108 | .with( Mutator::setCacheDir, | |
| 1109 | () -> w.getFile( KEY_CACHE_DIR ) ) | |
| 1110 | .with( Mutator::setImageDir, | |
| 1111 | () -> w.getFile( KEY_IMAGE_DIR ) ) | |
| 1112 | .with( Mutator::setImageOrder, | |
| 1113 | () -> w.getString( KEY_IMAGE_ORDER ) ) | |
| 1114 | .with( Mutator::setImageServer, | |
| 1115 | () -> w.getString( KEY_IMAGE_SERVER ) ) | |
| 1116 | .with( Mutator::setCaret, | |
| 1117 | () -> getTextEditor().getCaret() ) | |
| 1118 | .with( Mutator::setSigilBegan, | |
| 1119 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 1120 | .with( Mutator::setSigilEnded, | |
| 1121 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 1122 | .with( Mutator::setRScript, | |
| 1123 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 1124 | .with( Mutator::setRWorkingDir, | |
| 1125 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 1126 | .with( Mutator::setFontDir, | |
| 1127 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | |
| 1128 | .with( Mutator::setEnableMode, | |
| 1129 | () -> w.getString( KEY_TYPESET_MODES_ENABLED ) ) | |
| 1130 | .with( Mutator::setCurlQuotes, | |
| 1131 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 1132 | .with( Mutator::setAutoRemove, | |
| 1133 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 1134 | } | |
| 1135 | ||
| 1136 | public ProcessorContext createProcessorContext() { | |
| 1137 | return createProcessorContextBuilder( NONE ).build(); | |
| 1138 | } | |
| 1139 | ||
| 1140 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( | |
| 1141 | final ExportFormat format ) { | |
| 1142 | final var textEditor = getTextEditor(); | |
| 1143 | final var sourcePath = textEditor.getPath(); | |
| 1144 | ||
| 1145 | return processorContextBuilder() | |
| 1146 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1147 | .with( Mutator::setExportFormat, format ); | |
| 1148 | } | |
| 1149 | ||
| 1150 | /** | |
| 1151 | * @param targetPath Used when exporting to a PDF file (binary). | |
| 1152 | * @param format Used when processors export to a new text format. | |
| 1153 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1154 | * {@link Processor}. | |
| 1155 | */ | |
| 1156 | public ProcessorContext createProcessorContext( | |
| 1157 | final Path targetPath, final ExportFormat format ) { | |
| 1158 | assert targetPath != null; | |
| 1159 | assert format != null; | |
| 1160 | ||
| 1161 | return createProcessorContextBuilder( format ) | |
| 1162 | .with( Mutator::setTargetPath, targetPath ) | |
| 1163 | .build(); | |
| 1164 | } | |
| 1165 | ||
| 1166 | /** | |
| 1167 | * @param sourcePath Used by {@link ProcessorFactory} to determine | |
| 1168 | * {@link Processor} type to create based on file type. | |
| 1169 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1170 | * {@link Processor}. | |
| 1171 | */ | |
| 1172 | private ProcessorContext createProcessorContext( final Path sourcePath ) { | |
| 1173 | return processorContextBuilder() | |
| 1174 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1175 | .with( Mutator::setExportFormat, NONE ) | |
| 1176 | .build(); | |
| 1177 | } | |
| 1178 | ||
| 1179 | private TextResource createTextResource( final File file ) { | |
| 1180 | if( fromFilename( file ) == TEXT_YAML ) { | |
| 1181 | final var editor = createDefinitionEditor( file ); | |
| 1182 | mDefinitionEditor.set( editor ); | |
| 1183 | return editor; | |
| 1184 | } | |
| 1185 | else { | |
| 1186 | final var editor = createMarkdownEditor( file ); | |
| 1187 | mTextEditor.set( editor ); | |
| 1188 | return editor; | |
| 1189 | } | |
| 1190 | } | |
| 1191 | ||
| 1192 | /** | |
| 1193 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1194 | * caret change events and text change events. Text change events must | |
| 1195 | * take priority over caret change events because it's possible to change | |
| 1196 | * the text without moving the caret (e.g., delete selected text). | |
| 1197 | * | |
| 1198 | * @param inputFile The file containing contents for the text editor. | |
| 1199 | * @return A non-null text editor. | |
| 1200 | */ | |
| 1201 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { | |
| 1202 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1203 | ||
| 1204 | // Listener for editor modifications or caret position changes. | |
| 1205 | editor.addDirtyListener( ( _, _, n ) -> { | |
| 1206 | if( n ) { | |
| 1207 | // Reset the status bar after changing the text. | |
| 1208 | clue(); | |
| 1209 | ||
| 1210 | // Processing the text may update the status bar. | |
| 1211 | process( editor ); | |
| 1212 | ||
| 1213 | // Update the caret position in the status bar. | |
| 1214 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1215 | } | |
| 1216 | } ); | |
| 1217 | ||
| 1218 | editor.addEventListener( | |
| 1219 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1220 | ); | |
| 1221 | ||
| 1222 | editor.addEventListener( | |
| 1223 | keyPressed( ENTER, ALT_DOWN ), _ -> mEditorSpeller.autofix( editor ) | |
| 1224 | ); | |
| 1225 | ||
| 1226 | final var textArea = editor.getTextArea(); | |
| 1227 | ||
| 1228 | // Spell check when the paragraph changes. | |
| 1229 | textArea | |
| 1230 | .plainTextChanges() | |
| 1231 | .filter( p -> !p.isIdentity() ) | |
| 1232 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | |
| 1233 | ||
| 1234 | // Store the caret position to restore it after restarting the application. | |
| 1235 | textArea.caretPositionProperty().addListener( | |
| 1236 | ( _, _, n ) -> | |
| 1237 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | |
| 1238 | ); | |
| 1239 | ||
| 1240 | // Check the entire document after the spellchecker is initialized (with | |
| 1241 | // a valid lexicon) so that only the current paragraph need be scanned | |
| 1242 | // while editing. (Technically, only the most recently modified word must | |
| 1243 | // be scanned.) | |
| 1244 | mSpellChecker.addListener( | |
| 1245 | ( _, _, _ ) -> runLater( | |
| 1246 | () -> iterateEditors( mEditorSpeller::checkDocument ) | |
| 1247 | ) | |
| 1248 | ); | |
| 1249 | ||
| 1250 | // Check the entire document after it has been loaded. | |
| 1251 | mEditorSpeller.checkDocument( editor ); | |
| 1252 | ||
| 1253 | return editor; | |
| 1254 | } | |
| 1255 | ||
| 1256 | /** | |
| 1257 | * Creates a processor for an editor, provided one doesn't already exist. | |
| 1258 | * | |
| 1259 | * @param editor The editor that potentially requires an associated processor. | |
| 1260 | */ | |
| 1261 | private void updateProcessors( final TextEditor editor ) { | |
| 1262 | final var path = editor.getFile().toPath(); | |
| 1263 | ||
| 1264 | mProcessors.computeIfAbsent( | |
| 1265 | editor, _ -> createProcessors( | |
| 1266 | createProcessorContext( path ), | |
| 1267 | createHtmlPreviewProcessor() | |
| 1268 | ) | |
| 1269 | ); | |
| 1270 | } | |
| 1271 | ||
| 1272 | /** | |
| 1273 | * Removes a processor for an editor. This is required because a file may | |
| 1274 | * change type while editing (e.g., from plain Markdown to R Markdown). | |
| 1275 | * In the case that an editor's type changes, its associated processor must | |
| 1276 | * be changed accordingly. | |
| 1277 | * | |
| 1278 | * @param editor The editor that potentially requires an associated processor. | |
| 1279 | */ | |
| 1280 | private void removeProcessor( final TextEditor editor ) { | |
| 1281 | mProcessors.remove( editor ); | |
| 1282 | } | |
| 1283 | ||
| 1284 | /** | |
| 1285 | * Creates a {@link Processor} capable of rendering an HTML document onto | |
| 1286 | * a GUI widget. | |
| 1287 | * | |
| 1288 | * @return The {@link Processor} for rendering an HTML document. | |
| 1289 | */ | |
| 1290 | private Processor<String> createHtmlPreviewProcessor() { | |
| 1291 | return new HtmlPreviewProcessor( getPreview() ); | |
| 1292 | } | |
| 1293 | ||
| 1294 | /** | |
| 1295 | * Creates a spellchecker that accepts all words as correct. This allows | |
| 1296 | * the spellchecker property to be initialized to a known valid value. | |
| 1297 | * | |
| 1298 | * @return A wrapped {@link PermissiveSpeller}. | |
| 1299 | */ | |
| 1300 | private ObjectProperty<SpellChecker> createSpellChecker() { | |
| 1301 | return new SimpleObjectProperty<>( new PermissiveSpeller() ); | |
| 1302 | } | |
| 1303 | ||
| 1304 | private TextEditorSpellChecker createTextEditorSpellChecker( | |
| 1305 | final ObjectProperty<SpellChecker> spellChecker ) { | |
| 1306 | return new TextEditorSpellChecker( spellChecker ); | |
| 1307 | } | |
| 1308 | ||
| 1309 | /** | |
| 1310 | * Delegates to {@link #autoinsert()}. | |
| 1311 | * | |
| 1312 | * @param ignored Ignored. | |
| 1313 | */ | |
| 1314 | private void autoinsert( final KeyEvent ignored ) { | |
| 1313 | 1315 | autoinsert(); |
| 1314 | 1316 | } |
| 39 | 39 | public final class Arguments implements Callable<Integer> { |
| 40 | 40 | @CommandLine.Option( |
| 41 | names = {"--all"}, | |
| 41 | names = { "--all" }, | |
| 42 | 42 | description = |
| 43 | 43 | "Concatenate files before processing (${DEFAULT-VALUE})", |
| 44 | 44 | defaultValue = "false" |
| 45 | 45 | ) |
| 46 | 46 | private boolean mConcatenate; |
| 47 | 47 | |
| 48 | 48 | @CommandLine.Option( |
| 49 | names = {"--keep-files"}, | |
| 49 | names = { "--keep-files" }, | |
| 50 | 50 | description = |
| 51 | 51 | "Retain temporary build files (${DEFAULT-VALUE})", |
| 52 | 52 | defaultValue = "false" |
| 53 | 53 | ) |
| 54 | 54 | private boolean mKeepFiles; |
| 55 | 55 | |
| 56 | 56 | @CommandLine.Option( |
| 57 | names = {"-c", "--chapters"}, | |
| 57 | names = { "-c", "--chapters" }, | |
| 58 | 58 | description = |
| 59 | 59 | "Export chapter ranges, no spaces (e.g., -3,5-9,15-)", |
| 60 | 60 | paramLabel = "String" |
| 61 | 61 | ) |
| 62 | 62 | private String mChapters; |
| 63 | 63 | |
| 64 | 64 | @CommandLine.Option( |
| 65 | names = {"--curl-quotes"}, | |
| 65 | names = { "--curl-quotes" }, | |
| 66 | 66 | description = |
| 67 | 67 | "Replace straight quotes with curly quotes (${DEFAULT-VALUE})", |
| 68 | 68 | defaultValue = "true" |
| 69 | 69 | ) |
| 70 | 70 | private boolean mCurlQuotes; |
| 71 | 71 | |
| 72 | 72 | @CommandLine.Option( |
| 73 | names = {"-d", "--debug"}, | |
| 73 | names = { "-d", "--debug" }, | |
| 74 | 74 | description = |
| 75 | 75 | "Enable logging to the console (${DEFAULT-VALUE})", |
| 76 | 76 | paramLabel = "Boolean", |
| 77 | 77 | defaultValue = "false" |
| 78 | 78 | ) |
| 79 | 79 | private boolean mDebug; |
| 80 | 80 | |
| 81 | 81 | @CommandLine.Option( |
| 82 | names = {"-i", "--input"}, | |
| 82 | names = { "-i", "--input" }, | |
| 83 | 83 | description = |
| 84 | 84 | "Source document file path", |
| 85 | 85 | paramLabel = "PATH", |
| 86 | 86 | defaultValue = "stdin", |
| 87 | 87 | required = true |
| 88 | 88 | ) |
| 89 | 89 | private Path mSourcePath; |
| 90 | 90 | |
| 91 | 91 | @CommandLine.Option( |
| 92 | names = {"--font-dir"}, | |
| 92 | names = { "--font-dir" }, | |
| 93 | 93 | description = |
| 94 | 94 | "Directory to specify additional fonts", |
| 95 | 95 | paramLabel = "String" |
| 96 | 96 | ) |
| 97 | 97 | private File mFontDir; |
| 98 | 98 | |
| 99 | 99 | @CommandLine.Option( |
| 100 | names = {"--format-subtype"}, | |
| 100 | names = { "--mode" }, | |
| 101 | description = | |
| 102 | "Enable one or more modes when typesetting", | |
| 103 | paramLabel = "String" | |
| 104 | ) | |
| 105 | private String mEnableMode; | |
| 106 | ||
| 107 | @CommandLine.Option( | |
| 108 | names = { "--format-subtype" }, | |
| 101 | 109 | description = |
| 102 | 110 | "Export TeX subtype for HTML formats: svg, delimited", |
| 103 | 111 | paramLabel = "String", |
| 104 | 112 | defaultValue = "svg" |
| 105 | 113 | ) |
| 106 | 114 | private String mFormatSubtype; |
| 107 | 115 | |
| 108 | 116 | @CommandLine.Option( |
| 109 | names = {"--cache-dir"}, | |
| 117 | names = { "--cache-dir" }, | |
| 110 | 118 | description = |
| 111 | 119 | "Directory to store remote resources", |
| 112 | 120 | paramLabel = "DIR" |
| 113 | 121 | ) |
| 114 | 122 | private File mCachesDir; |
| 115 | 123 | |
| 116 | 124 | @CommandLine.Option( |
| 117 | names = {"--image-dir"}, | |
| 125 | names = { "--image-dir" }, | |
| 118 | 126 | description = |
| 119 | 127 | "Directory containing images", |
| 120 | 128 | paramLabel = "DIR" |
| 121 | 129 | ) |
| 122 | 130 | private File mImagesDir; |
| 123 | 131 | |
| 124 | 132 | @CommandLine.Option( |
| 125 | names = {"--image-order"}, | |
| 133 | names = { "--image-order" }, | |
| 126 | 134 | description = |
| 127 | 135 | "Comma-separated image order (${DEFAULT-VALUE})", |
| 128 | 136 | paramLabel = "String", |
| 129 | 137 | defaultValue = "svg,pdf,png,jpg,tiff" |
| 130 | 138 | ) |
| 131 | 139 | private String mImageOrder; |
| 132 | 140 | |
| 133 | 141 | @CommandLine.Option( |
| 134 | names = {"--image-server"}, | |
| 142 | names = { "--image-server" }, | |
| 135 | 143 | description = |
| 136 | 144 | "SVG diagram rendering service (${DEFAULT-VALUE})", |
| 137 | 145 | paramLabel = "String", |
| 138 | 146 | defaultValue = DIAGRAM_SERVER_NAME |
| 139 | 147 | ) |
| 140 | 148 | private String mImageServer; |
| 141 | 149 | |
| 142 | 150 | @CommandLine.Option( |
| 143 | names = {"--locale"}, | |
| 151 | names = { "--locale" }, | |
| 144 | 152 | description = |
| 145 | 153 | "Set localization (${DEFAULT-VALUE})", |
| 146 | 154 | paramLabel = "String", |
| 147 | 155 | defaultValue = "en" |
| 148 | 156 | ) |
| 149 | 157 | private String mLocale; |
| 150 | 158 | |
| 151 | 159 | @CommandLine.Option( |
| 152 | names = {"-m", "--metadata"}, | |
| 160 | names = { "-m", "--metadata" }, | |
| 153 | 161 | description = |
| 154 | 162 | "Map metadata keys to values, variable names allowed", |
| 155 | 163 | paramLabel = "key=value" |
| 156 | 164 | ) |
| 157 | 165 | private Map<String, String> mMetadata; |
| 158 | 166 | |
| 159 | 167 | @CommandLine.Option( |
| 160 | names = {"-o", "--output"}, | |
| 168 | names = { "-o", "--output" }, | |
| 161 | 169 | description = |
| 162 | 170 | "Destination document file path", |
| 163 | 171 | paramLabel = "PATH", |
| 164 | 172 | defaultValue = "stdout", |
| 165 | 173 | required = true |
| 166 | 174 | ) |
| 167 | 175 | private Path mTargetPath; |
| 168 | 176 | |
| 169 | 177 | @CommandLine.Option( |
| 170 | names = {"-q", "--quiet"}, | |
| 178 | names = { "-q", "--quiet" }, | |
| 171 | 179 | description = |
| 172 | 180 | "Suppress all status messages (${DEFAULT-VALUE})", |
| 173 | 181 | defaultValue = "false" |
| 174 | 182 | ) |
| 175 | 183 | private boolean mQuiet; |
| 176 | 184 | |
| 177 | 185 | @CommandLine.Option( |
| 178 | names = {"--r-dir"}, | |
| 186 | names = { "--r-dir" }, | |
| 179 | 187 | description = |
| 180 | 188 | "R working directory", |
| 181 | 189 | paramLabel = "DIR" |
| 182 | 190 | ) |
| 183 | 191 | private Path mRWorkingDir; |
| 184 | 192 | |
| 185 | 193 | @CommandLine.Option( |
| 186 | names = {"--r-script"}, | |
| 194 | names = { "--r-script" }, | |
| 187 | 195 | description = |
| 188 | 196 | "R bootstrap script file path", |
| 189 | 197 | paramLabel = "PATH" |
| 190 | 198 | ) |
| 191 | 199 | private Path mRScriptPath; |
| 192 | 200 | |
| 193 | 201 | @CommandLine.Option( |
| 194 | names = {"-s", "--set"}, | |
| 202 | names = { "-s", "--set" }, | |
| 195 | 203 | description = |
| 196 | 204 | "Set (or override) a document variable value", |
| 197 | 205 | paramLabel = "key=value" |
| 198 | 206 | ) |
| 199 | 207 | private Map<String, String> mOverrides; |
| 200 | 208 | |
| 201 | 209 | @CommandLine.Option( |
| 202 | names = {"--sigil-opening"}, | |
| 210 | names = { "--sigil-opening" }, | |
| 203 | 211 | description = |
| 204 | 212 | "Starting sigil for variable names (${DEFAULT-VALUE})", |
| 205 | 213 | paramLabel = "String", |
| 206 | 214 | defaultValue = "{{" |
| 207 | 215 | ) |
| 208 | 216 | private String mSigilBegan; |
| 209 | 217 | |
| 210 | 218 | @CommandLine.Option( |
| 211 | names = {"--sigil-closing"}, | |
| 219 | names = { "--sigil-closing" }, | |
| 212 | 220 | description = |
| 213 | 221 | "Ending sigil for variable names (${DEFAULT-VALUE})", |
| 214 | 222 | paramLabel = "String", |
| 215 | 223 | defaultValue = "}}" |
| 216 | 224 | ) |
| 217 | 225 | private String mSigilEnded; |
| 218 | 226 | |
| 219 | 227 | @CommandLine.Option( |
| 220 | names = {"--theme-dir"}, | |
| 228 | names = { "--theme-dir" }, | |
| 221 | 229 | description = |
| 222 | 230 | "Theme directory", |
| 223 | 231 | paramLabel = "DIR" |
| 224 | 232 | ) |
| 225 | 233 | private Path mThemesDir; |
| 226 | 234 | |
| 227 | 235 | @CommandLine.Option( |
| 228 | names = {"-v", "--variables"}, | |
| 236 | names = { "-v", "--variables" }, | |
| 229 | 237 | description = |
| 230 | 238 | "Variables file path", |
| ... | ||
| 256 | 264 | .with( Mutator::setImageOrder, () -> mImageOrder ) |
| 257 | 265 | .with( Mutator::setFontDir, () -> mFontDir ) |
| 266 | .with( Mutator::setEnableMode, () -> mEnableMode ) | |
| 258 | 267 | .with( Mutator::setExportFormat, format ) |
| 259 | 268 | .with( Mutator::setDefinitions, () -> definitions ) |
| ... | ||
| 338 | 347 | |
| 339 | 348 | final var jsonNode = node.getValue(); |
| 340 | final var keyName = parent + "." + node.getKey(); | |
| 349 | final var keyName = STR."\{parent}.\{node.getKey()}"; | |
| 341 | 350 | |
| 342 | 351 | if( jsonNode.isValueNode() ) { |
| 18 | 18 | import static com.keenwrite.io.SysFile.toFile; |
| 19 | 19 | import static com.keenwrite.preferences.LocaleScripts.withScript; |
| 20 | import static com.keenwrite.util.SystemUtils.*; | |
| 20 | 21 | import static java.io.File.separator; |
| 21 | 22 | import static java.lang.String.format; |
| 22 | 23 | import static java.lang.System.getProperty; |
| 23 | import static org.apache.commons.lang3.SystemUtils.*; | |
| 24 | 24 | |
| 25 | 25 | /** |
| 122 | 122 | final var buttonBar = new HBox(); |
| 123 | 123 | buttonBar.getChildren().addAll( |
| 124 | createButton( "create", e -> createDefinition() ), | |
| 125 | createButton( "rename", e -> renameDefinition() ), | |
| 126 | createButton( "delete", e -> deleteDefinitions() ) | |
| 127 | ); | |
| 128 | buttonBar.setAlignment( CENTER ); | |
| 129 | buttonBar.setSpacing( UI_CONTROL_SPACING ); | |
| 130 | setTop( buttonBar ); | |
| 131 | setCenter( mTreeView ); | |
| 132 | setAlignment( buttonBar, TOP_CENTER ); | |
| 133 | ||
| 134 | mEncoding = open( mFile ); | |
| 135 | updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | |
| 136 | ||
| 137 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 138 | // upon saving, users will be prompted to save a file that hasn't had | |
| 139 | // any modifications (from their perspective). | |
| 140 | addTreeChangeHandler( event -> { | |
| 141 | mModified.set( true ); | |
| 142 | updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | |
| 143 | } ); | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Replaces the given list of variable definitions with a flat hierarchy | |
| 148 | * of the converted {@link TreeView} root. | |
| 149 | * | |
| 150 | * @param definitions The definition map to update. | |
| 151 | * @param root The values to flatten then insert into the map. | |
| 152 | */ | |
| 153 | private void updateDefinitions( | |
| 154 | final Map<String, String> definitions, | |
| 155 | final TreeItem<String> root ) { | |
| 156 | definitions.clear(); | |
| 157 | definitions.putAll( TreeItemMapper.convert( root ) ); | |
| 158 | Engine.clear(); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Returns the variable definitions. | |
| 163 | * | |
| 164 | * @return The definition map. | |
| 165 | */ | |
| 166 | @Override | |
| 167 | public Map<String, String> getDefinitions() { | |
| 168 | return mDefinitions; | |
| 169 | } | |
| 170 | ||
| 171 | @Override | |
| 172 | public void setText( final String document ) { | |
| 173 | final var foster = mTreeTransformer.transform( document ); | |
| 174 | final var biological = getTreeRoot(); | |
| 175 | ||
| 176 | for( final var child : foster.getChildren() ) { | |
| 177 | biological.getChildren().add( child ); | |
| 178 | } | |
| 179 | ||
| 180 | getTreeView().refresh(); | |
| 181 | } | |
| 182 | ||
| 183 | @Override | |
| 184 | public String getText() { | |
| 185 | final var result = new StringBuilder( 32768 ); | |
| 186 | ||
| 187 | try { | |
| 188 | result.append( mTreeTransformer.transform( getTreeView().getRoot() ) ); | |
| 189 | ||
| 190 | final var problem = isTreeWellFormed(); | |
| 191 | problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) ); | |
| 192 | } catch( final Exception ex ) { | |
| 193 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 194 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 195 | clue( ex ); | |
| 196 | } | |
| 197 | ||
| 198 | return result.toString(); | |
| 199 | } | |
| 200 | ||
| 201 | @Override | |
| 202 | public File getFile() { | |
| 203 | return mFile; | |
| 204 | } | |
| 205 | ||
| 206 | @Override | |
| 207 | public void rename( final File file ) { | |
| 208 | mFile = file; | |
| 209 | } | |
| 210 | ||
| 211 | @Override | |
| 212 | public Charset getEncoding() { | |
| 213 | return mEncoding; | |
| 214 | } | |
| 215 | ||
| 216 | @Override | |
| 217 | public Node getNode() { | |
| 218 | return this; | |
| 219 | } | |
| 220 | ||
| 221 | @Override | |
| 222 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 223 | return mModified; | |
| 224 | } | |
| 225 | ||
| 226 | @Override | |
| 227 | public void clearModifiedProperty() { | |
| 228 | mModified.setValue( false ); | |
| 229 | } | |
| 230 | ||
| 231 | private Button createButton( | |
| 232 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 233 | final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey; | |
| 234 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 235 | final var graphic = createGraphic( get( keyPrefix + ".icon" ) ); | |
| 236 | ||
| 237 | button.setOnAction( eventHandler ); | |
| 238 | button.setGraphic( graphic ); | |
| 239 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 240 | ||
| 241 | return button; | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 246 | * is modified. The modifications include: item value changes, item additions, | |
| 247 | * and item removals. | |
| 248 | * <p> | |
| 249 | * Safe to call multiple times; if a handler is already registered, the | |
| 250 | * old handler is used. | |
| 251 | * </p> | |
| 252 | * | |
| 253 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 254 | */ | |
| 255 | public void addTreeChangeHandler( | |
| 256 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 257 | final var root = getTreeView().getRoot(); | |
| 258 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 259 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 260 | } | |
| 261 | ||
| 262 | /** | |
| 263 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 264 | * well-formed for export. A tree is considered well-formed if the following | |
| 265 | * conditions are met: | |
| 266 | * | |
| 267 | * <ul> | |
| 268 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 269 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 270 | * </ul> | |
| 271 | * | |
| 272 | * @return {@code null} if the document is well-formed, otherwise the | |
| 273 | * problematic child {@link TreeItem}. | |
| 274 | */ | |
| 275 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 276 | final var root = getTreeView().getRoot(); | |
| 277 | ||
| 278 | for( final var child : root.getChildren() ) { | |
| 279 | final var problemChild = isWellFormed( child ); | |
| 280 | ||
| 281 | if( child.isLeaf() || problemChild != null ) { | |
| 282 | return Optional.ofNullable( problemChild ); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | return Optional.empty(); | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Determines whether the document is well-formed by ensuring that | |
| 291 | * child branches do not contain multiple leaves. | |
| 292 | * | |
| 293 | * @param item The subtree to check for well-formedness. | |
| 294 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 295 | * problematic {@link TreeItem}. | |
| 296 | */ | |
| 297 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 298 | int childLeafs = 0; | |
| 299 | int childBranches = 0; | |
| 300 | ||
| 301 | for( final var child : item.getChildren() ) { | |
| 302 | if( child.isLeaf() ) { | |
| 303 | childLeafs++; | |
| 304 | } | |
| 305 | else { | |
| 306 | childBranches++; | |
| 307 | } | |
| 308 | ||
| 309 | final var problemChild = isWellFormed( child ); | |
| 310 | ||
| 311 | if( problemChild != null ) { | |
| 312 | return problemChild; | |
| 313 | } | |
| 314 | } | |
| 315 | ||
| 316 | return ((childBranches > 0 && childLeafs == 0) || | |
| 317 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 318 | } | |
| 319 | ||
| 320 | @Override | |
| 321 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 322 | return getTreeRoot().findLeafExact( text ); | |
| 323 | } | |
| 324 | ||
| 325 | @Override | |
| 326 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 327 | return getTreeRoot().findLeafContains( text ); | |
| 328 | } | |
| 329 | ||
| 330 | @Override | |
| 331 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 332 | final String text ) { | |
| 333 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 334 | } | |
| 335 | ||
| 336 | @Override | |
| 337 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 338 | return getTreeRoot().findLeafStartsWith( text ); | |
| 339 | } | |
| 340 | ||
| 341 | public void select( final TreeItem<String> item ) { | |
| 342 | getSelectionModel().clearSelection(); | |
| 343 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 344 | } | |
| 345 | ||
| 346 | /** | |
| 347 | * Collapses the tree, recursively. | |
| 348 | */ | |
| 349 | public void collapse() { | |
| 350 | collapse( getTreeRoot().getChildren() ); | |
| 351 | } | |
| 352 | ||
| 353 | /** | |
| 354 | * Collapses the tree, recursively. | |
| 355 | * | |
| 356 | * @param <T> The type of tree item to expand (usually String). | |
| 357 | * @param nodes The nodes to collapse. | |
| 358 | */ | |
| 359 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 360 | for( final var node : nodes ) { | |
| 361 | node.setExpanded( false ); | |
| 362 | collapse( node.getChildren() ); | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | /** | |
| 367 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 368 | */ | |
| 369 | private boolean isEditingTreeItem() { | |
| 370 | return getTreeView().editingItemProperty().getValue() != null; | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Changes to edit mode for the selected item. | |
| 375 | */ | |
| 376 | @Override | |
| 377 | public void renameDefinition() { | |
| 378 | getTreeView().edit( getSelectedItem() ); | |
| 379 | } | |
| 380 | ||
| 381 | /** | |
| 382 | * Removes all selected items from the {@link TreeView}. | |
| 383 | */ | |
| 384 | @Override | |
| 385 | public void deleteDefinitions() { | |
| 386 | for( final var item : getSelectedItems() ) { | |
| 387 | final var parent = item.getParent(); | |
| 388 | ||
| 389 | if( parent != null ) { | |
| 390 | parent.getChildren().remove( item ); | |
| 391 | } | |
| 392 | } | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * Deletes the selected item. | |
| 397 | */ | |
| 398 | private void deleteSelectedItem() { | |
| 399 | final var c = getSelectedItem(); | |
| 400 | getSiblings( c ).remove( c ); | |
| 401 | } | |
| 402 | ||
| 403 | private void insertSelectedItem() { | |
| 404 | if( getSelectedItem() instanceof DefinitionTreeItem<String> node ) { | |
| 405 | if( node.isLeaf() ) { | |
| 406 | InsertDefinitionEvent.fire( node ); | |
| 407 | } | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | /** | |
| 412 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 413 | * There are a few conditions to consider: when adding to the root, | |
| 414 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 415 | * root must contain two items: a key and a value. | |
| 416 | */ | |
| 417 | @Override | |
| 418 | public void createDefinition() { | |
| 419 | final var value = createDefinitionTreeItem(); | |
| 420 | getSelectedItem().getChildren().add( value ); | |
| 421 | expand( value ); | |
| 422 | select( value ); | |
| 423 | } | |
| 424 | ||
| 425 | private ContextMenu createContextMenu() { | |
| 426 | final var menu = new ContextMenu(); | |
| 427 | final var items = menu.getItems(); | |
| 428 | ||
| 429 | addMenuItem( items, ACTION_PREFIX + "definition.create.text" ) | |
| 430 | .setOnAction( e -> createDefinition() ); | |
| 431 | addMenuItem( items, ACTION_PREFIX + "definition.rename.text" ) | |
| 432 | .setOnAction( e -> renameDefinition() ); | |
| 433 | addMenuItem( items, ACTION_PREFIX + "definition.delete.text" ) | |
| 434 | .setOnAction( e -> deleteSelectedItem() ); | |
| 435 | addMenuItem( items, ACTION_PREFIX + "definition.insert.text" ) | |
| 436 | .setOnAction( e -> insertSelectedItem() ); | |
| 437 | ||
| 438 | return menu; | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Executes hot-keys for edits to the definition tree. | |
| 443 | * | |
| 444 | * @param event Contains the key code of the key that was pressed. | |
| 445 | */ | |
| 446 | private void keyEventFilter( final KeyEvent event ) { | |
| 447 | if( !isEditingTreeItem() ) { | |
| 448 | switch( event.getCode() ) { | |
| 449 | case ENTER -> { | |
| 450 | expand( getSelectedItem() ); | |
| 451 | event.consume(); | |
| 452 | } | |
| 453 | ||
| 454 | case DELETE -> deleteDefinitions(); | |
| 455 | case INSERT -> createDefinition(); | |
| 456 | ||
| 457 | case R -> { | |
| 458 | if( event.isControlDown() ) { | |
| 459 | renameDefinition(); | |
| 460 | } | |
| 461 | } | |
| 462 | ||
| 463 | default -> { } | |
| 464 | } | |
| 465 | ||
| 466 | for( final var handler : getKeyEventHandlers() ) { | |
| 467 | handler.handle( event ); | |
| 468 | } | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | /** | |
| 473 | * Called when the editor's input focus changes. This will fire an event | |
| 474 | * for subscribers. | |
| 475 | * | |
| 476 | * @param ignored Not used. | |
| 477 | * @param o The old input focus property value. | |
| 478 | * @param n The new input focus property value. | |
| 479 | */ | |
| 480 | private void focused( | |
| 481 | final ObservableValue<? extends Boolean> ignored, | |
| 482 | final Boolean o, | |
| 483 | final Boolean n ) { | |
| 484 | if( n != null && n ) { | |
| 485 | TextDefinitionFocusEvent.fire( this ); | |
| 486 | } | |
| 487 | } | |
| 488 | ||
| 489 | /** | |
| 490 | * Adds a menu item to a list of menu items. | |
| 491 | * | |
| 492 | * @param items The list of menu items to append to. | |
| 493 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 494 | * @return The menu item added to the list of menu items. | |
| 495 | */ | |
| 496 | private MenuItem addMenuItem( | |
| 497 | final List<MenuItem> items, final String labelKey ) { | |
| 498 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 499 | items.add( menuItem ); | |
| 500 | return menuItem; | |
| 501 | } | |
| 502 | ||
| 503 | private MenuItem createMenuItem( final String labelKey ) { | |
| 504 | return new MenuItem( get( labelKey ) ); | |
| 505 | } | |
| 506 | ||
| 507 | /** | |
| 508 | * Creates a new {@link TreeItem} that is intended to be the root-level item | |
| 509 | * added to the {@link TreeView}. This allows the root item to be | |
| 510 | * distinguished from the other items so that reference keys do not include | |
| 511 | * "Definition" as part of their name. | |
| 512 | * | |
| 513 | * @return A new {@link TreeItem}, never {@code null}. | |
| 514 | */ | |
| 515 | private RootTreeItem<String> createRootTreeItem() { | |
| 516 | return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | |
| 517 | } | |
| 518 | ||
| 519 | private DefinitionTreeItem<String> createDefinitionTreeItem() { | |
| 520 | return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 521 | } | |
| 522 | ||
| 523 | @Override | |
| 524 | public void requestFocus() { | |
| 525 | getTreeView().requestFocus(); | |
| 526 | } | |
| 527 | ||
| 528 | /** | |
| 529 | * Expands the node to the root, recursively. | |
| 530 | * | |
| 531 | * @param <T> The type of tree item to expand (usually String). | |
| 532 | * @param node The node to expand. | |
| 533 | */ | |
| 534 | @Override | |
| 535 | public <T> void expand( final TreeItem<T> node ) { | |
| 536 | if( node != null ) { | |
| 537 | expand( node.getParent() ); | |
| 538 | node.setExpanded( !node.isLeaf() ); | |
| 539 | } | |
| 540 | } | |
| 541 | ||
| 542 | /** | |
| 543 | * Answers whether there are any definitions in the tree. | |
| 544 | * | |
| 545 | * @return {@code true} when there are no definitions; {@code false} when | |
| 546 | * there's at least one definition. | |
| 547 | */ | |
| 548 | @Override | |
| 549 | public boolean isEmpty() { | |
| 550 | return getTreeRoot().isEmpty(); | |
| 551 | } | |
| 552 | ||
| 553 | /** | |
| 554 | * Returns the actively selected item in the tree. | |
| 555 | * | |
| 556 | * @return The selected item, or the tree root item if no item is selected. | |
| 557 | */ | |
| 558 | public TreeItem<String> getSelectedItem() { | |
| 559 | final var item = getSelectionModel().getSelectedItem(); | |
| 560 | return item == null ? getTreeRoot() : item; | |
| 561 | } | |
| 562 | ||
| 563 | /** | |
| 564 | * Returns the {@link TreeView} that contains the definition hierarchy. | |
| 565 | * | |
| 566 | * @return A non-null instance. | |
| 567 | */ | |
| 568 | private TreeView<String> getTreeView() { | |
| 569 | return mTreeView; | |
| 570 | } | |
| 571 | ||
| 572 | /** | |
| 573 | * Returns the root of the tree. | |
| 574 | * | |
| 575 | * @return The first node added to the definition tree. | |
| 576 | */ | |
| 577 | private DefinitionTreeItem<String> getTreeRoot() { | |
| 578 | return mTreeRoot; | |
| 579 | } | |
| 580 | ||
| 581 | private ObservableList<TreeItem<String>> getSiblings( | |
| 582 | final TreeItem<String> item ) { | |
| 583 | final var root = getTreeView().getRoot(); | |
| 584 | final var parent = (item == null || item == root) ? root : item.getParent(); | |
| 124 | createButton( "create", _ -> createDefinition() ), | |
| 125 | createButton( "rename", _ -> renameDefinition() ), | |
| 126 | createButton( "delete", _ -> deleteDefinitions() ) | |
| 127 | ); | |
| 128 | buttonBar.setAlignment( CENTER ); | |
| 129 | buttonBar.setSpacing( UI_CONTROL_SPACING ); | |
| 130 | setTop( buttonBar ); | |
| 131 | setCenter( mTreeView ); | |
| 132 | setAlignment( buttonBar, TOP_CENTER ); | |
| 133 | ||
| 134 | mEncoding = open( mFile ); | |
| 135 | updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | |
| 136 | ||
| 137 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 138 | // upon saving, users will be prompted to save a file that hasn't had | |
| 139 | // any modifications (from their perspective). | |
| 140 | addTreeChangeHandler( _ -> { | |
| 141 | mModified.set( true ); | |
| 142 | updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | |
| 143 | } ); | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Replaces the given list of variable definitions with a flat hierarchy | |
| 148 | * of the converted {@link TreeView} root. | |
| 149 | * | |
| 150 | * @param definitions The definition map to update. | |
| 151 | * @param root The values to flatten then insert into the map. | |
| 152 | */ | |
| 153 | private void updateDefinitions( | |
| 154 | final Map<String, String> definitions, | |
| 155 | final TreeItem<String> root ) { | |
| 156 | definitions.clear(); | |
| 157 | definitions.putAll( TreeItemMapper.convert( root ) ); | |
| 158 | Engine.clear(); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Returns the variable definitions. | |
| 163 | * | |
| 164 | * @return The definition map. | |
| 165 | */ | |
| 166 | @Override | |
| 167 | public Map<String, String> getDefinitions() { | |
| 168 | return mDefinitions; | |
| 169 | } | |
| 170 | ||
| 171 | @Override | |
| 172 | public void setText( final String document ) { | |
| 173 | final var foster = mTreeTransformer.transform( document ); | |
| 174 | final var biological = getTreeRoot(); | |
| 175 | ||
| 176 | for( final var child : foster.getChildren() ) { | |
| 177 | biological.getChildren().add( child ); | |
| 178 | } | |
| 179 | ||
| 180 | getTreeView().refresh(); | |
| 181 | } | |
| 182 | ||
| 183 | @Override | |
| 184 | public String getText() { | |
| 185 | final var result = new StringBuilder( 32768 ); | |
| 186 | ||
| 187 | try { | |
| 188 | result.append( mTreeTransformer.transform( getTreeView().getRoot() ) ); | |
| 189 | ||
| 190 | final var problem = isTreeWellFormed(); | |
| 191 | problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) ); | |
| 192 | } catch( final Exception ex ) { | |
| 193 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 194 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 195 | clue( ex ); | |
| 196 | } | |
| 197 | ||
| 198 | return result.toString(); | |
| 199 | } | |
| 200 | ||
| 201 | @Override | |
| 202 | public File getFile() { | |
| 203 | return mFile; | |
| 204 | } | |
| 205 | ||
| 206 | @Override | |
| 207 | public void rename( final File file ) { | |
| 208 | mFile = file; | |
| 209 | } | |
| 210 | ||
| 211 | @Override | |
| 212 | public Charset getEncoding() { | |
| 213 | return mEncoding; | |
| 214 | } | |
| 215 | ||
| 216 | @Override | |
| 217 | public Node getNode() { | |
| 218 | return this; | |
| 219 | } | |
| 220 | ||
| 221 | @Override | |
| 222 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 223 | return mModified; | |
| 224 | } | |
| 225 | ||
| 226 | @Override | |
| 227 | public void clearModifiedProperty() { | |
| 228 | mModified.setValue( false ); | |
| 229 | } | |
| 230 | ||
| 231 | private Button createButton( | |
| 232 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 233 | final var keyPrefix = STR."\{Constants.ACTION_PREFIX}definition.\{msgKey}"; | |
| 234 | final var button = new Button( get( STR."\{keyPrefix}.text" ) ); | |
| 235 | final var graphic = createGraphic( get( STR."\{keyPrefix}.icon" ) ); | |
| 236 | ||
| 237 | button.setOnAction( eventHandler ); | |
| 238 | button.setGraphic( graphic ); | |
| 239 | button.setTooltip( new Tooltip( get( STR."\{keyPrefix}.tooltip" ) ) ); | |
| 240 | ||
| 241 | return button; | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 246 | * is modified. The modifications include: item value changes, item additions, | |
| 247 | * and item removals. | |
| 248 | * <p> | |
| 249 | * Safe to call multiple times; if a handler is already registered, the | |
| 250 | * old handler is used. | |
| 251 | * </p> | |
| 252 | * | |
| 253 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 254 | */ | |
| 255 | public void addTreeChangeHandler( | |
| 256 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 257 | final var root = getTreeView().getRoot(); | |
| 258 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 259 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 260 | } | |
| 261 | ||
| 262 | /** | |
| 263 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 264 | * well-formed for export. A tree is considered well-formed if the following | |
| 265 | * conditions are met: | |
| 266 | * | |
| 267 | * <ul> | |
| 268 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 269 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 270 | * </ul> | |
| 271 | * | |
| 272 | * @return {@code null} if the document is well-formed, otherwise the | |
| 273 | * problematic child {@link TreeItem}. | |
| 274 | */ | |
| 275 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 276 | final var root = getTreeView().getRoot(); | |
| 277 | ||
| 278 | for( final var child : root.getChildren() ) { | |
| 279 | final var problemChild = isWellFormed( child ); | |
| 280 | ||
| 281 | if( child.isLeaf() || problemChild != null ) { | |
| 282 | return Optional.ofNullable( problemChild ); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | return Optional.empty(); | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Determines whether the document is well-formed by ensuring that | |
| 291 | * child branches do not contain multiple leaves. | |
| 292 | * | |
| 293 | * @param item The subtree to check for well-formedness. | |
| 294 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 295 | * problematic {@link TreeItem}. | |
| 296 | */ | |
| 297 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 298 | int childLeafs = 0; | |
| 299 | int childBranches = 0; | |
| 300 | ||
| 301 | for( final var child : item.getChildren() ) { | |
| 302 | if( child.isLeaf() ) { | |
| 303 | childLeafs++; | |
| 304 | } | |
| 305 | else { | |
| 306 | childBranches++; | |
| 307 | } | |
| 308 | ||
| 309 | final var problemChild = isWellFormed( child ); | |
| 310 | ||
| 311 | if( problemChild != null ) { | |
| 312 | return problemChild; | |
| 313 | } | |
| 314 | } | |
| 315 | ||
| 316 | return ((childBranches > 0 && childLeafs == 0) || | |
| 317 | (childBranches == 0 && childLeafs <= 1)) | |
| 318 | ? null | |
| 319 | : item; | |
| 320 | } | |
| 321 | ||
| 322 | @Override | |
| 323 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 324 | return getTreeRoot().findLeafExact( text ); | |
| 325 | } | |
| 326 | ||
| 327 | @Override | |
| 328 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 329 | return getTreeRoot().findLeafContains( text ); | |
| 330 | } | |
| 331 | ||
| 332 | @Override | |
| 333 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 334 | final String text ) { | |
| 335 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 336 | } | |
| 337 | ||
| 338 | @Override | |
| 339 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 340 | return getTreeRoot().findLeafStartsWith( text ); | |
| 341 | } | |
| 342 | ||
| 343 | public void select( final TreeItem<String> item ) { | |
| 344 | getSelectionModel().clearSelection(); | |
| 345 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 346 | } | |
| 347 | ||
| 348 | /** | |
| 349 | * Collapses the tree, recursively. | |
| 350 | */ | |
| 351 | public void collapse() { | |
| 352 | collapse( getTreeRoot().getChildren() ); | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Collapses the tree, recursively. | |
| 357 | * | |
| 358 | * @param <T> The type of tree item to expand (usually String). | |
| 359 | * @param nodes The nodes to collapse. | |
| 360 | */ | |
| 361 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 362 | for( final var node : nodes ) { | |
| 363 | node.setExpanded( false ); | |
| 364 | collapse( node.getChildren() ); | |
| 365 | } | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 370 | */ | |
| 371 | private boolean isEditingTreeItem() { | |
| 372 | return getTreeView().editingItemProperty().getValue() != null; | |
| 373 | } | |
| 374 | ||
| 375 | /** | |
| 376 | * Changes to edit mode for the selected item. | |
| 377 | */ | |
| 378 | @Override | |
| 379 | public void renameDefinition() { | |
| 380 | getTreeView().edit( getSelectedItem() ); | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * Removes all selected items from the {@link TreeView}. | |
| 385 | */ | |
| 386 | @Override | |
| 387 | public void deleteDefinitions() { | |
| 388 | for( final var item : getSelectedItems() ) { | |
| 389 | final var parent = item.getParent(); | |
| 390 | ||
| 391 | if( parent != null ) { | |
| 392 | parent.getChildren().remove( item ); | |
| 393 | } | |
| 394 | } | |
| 395 | } | |
| 396 | ||
| 397 | /** | |
| 398 | * Deletes the selected item. | |
| 399 | */ | |
| 400 | private void deleteSelectedItem() { | |
| 401 | final var c = getSelectedItem(); | |
| 402 | getSiblings( c ).remove( c ); | |
| 403 | } | |
| 404 | ||
| 405 | private void insertSelectedItem() { | |
| 406 | if( getSelectedItem() instanceof DefinitionTreeItem<String> node ) { | |
| 407 | if( node.isLeaf() ) { | |
| 408 | InsertDefinitionEvent.fire( node ); | |
| 409 | } | |
| 410 | } | |
| 411 | } | |
| 412 | ||
| 413 | /** | |
| 414 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 415 | * There are a few conditions to consider: when adding to the root, | |
| 416 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 417 | * root must contain two items: a key and a value. | |
| 418 | */ | |
| 419 | @Override | |
| 420 | public void createDefinition() { | |
| 421 | final var value = createDefinitionTreeItem(); | |
| 422 | getSelectedItem().getChildren().add( value ); | |
| 423 | expand( value ); | |
| 424 | select( value ); | |
| 425 | } | |
| 426 | ||
| 427 | private ContextMenu createContextMenu() { | |
| 428 | final var menu = new ContextMenu(); | |
| 429 | final var items = menu.getItems(); | |
| 430 | ||
| 431 | addMenuItem( items, STR."\{ACTION_PREFIX}definition.create.text" ) | |
| 432 | .setOnAction( _ -> createDefinition() ); | |
| 433 | addMenuItem( items, STR."\{ACTION_PREFIX}definition.rename.text" ) | |
| 434 | .setOnAction( _ -> renameDefinition() ); | |
| 435 | addMenuItem( items, STR."\{ACTION_PREFIX}definition.delete.text" ) | |
| 436 | .setOnAction( _ -> deleteSelectedItem() ); | |
| 437 | addMenuItem( items, STR."\{ACTION_PREFIX}definition.insert.text" ) | |
| 438 | .setOnAction( _ -> insertSelectedItem() ); | |
| 439 | ||
| 440 | return menu; | |
| 441 | } | |
| 442 | ||
| 443 | /** | |
| 444 | * Executes hot-keys for edits to the definition tree. | |
| 445 | * | |
| 446 | * @param event Contains the key code of the key that was pressed. | |
| 447 | */ | |
| 448 | private void keyEventFilter( final KeyEvent event ) { | |
| 449 | if( !isEditingTreeItem() ) { | |
| 450 | switch( event.getCode() ) { | |
| 451 | case ENTER -> { | |
| 452 | expand( getSelectedItem() ); | |
| 453 | event.consume(); | |
| 454 | } | |
| 455 | ||
| 456 | case DELETE -> deleteDefinitions(); | |
| 457 | case INSERT -> createDefinition(); | |
| 458 | ||
| 459 | case R -> { | |
| 460 | if( event.isControlDown() ) { | |
| 461 | renameDefinition(); | |
| 462 | } | |
| 463 | } | |
| 464 | ||
| 465 | default -> {} | |
| 466 | } | |
| 467 | ||
| 468 | for( final var handler : getKeyEventHandlers() ) { | |
| 469 | handler.handle( event ); | |
| 470 | } | |
| 471 | } | |
| 472 | } | |
| 473 | ||
| 474 | /** | |
| 475 | * Called when the editor's input focus changes. This will fire an event | |
| 476 | * for subscribers. | |
| 477 | * | |
| 478 | * @param ignored Not used. | |
| 479 | * @param o The old input focus property value. | |
| 480 | * @param n The new input focus property value. | |
| 481 | */ | |
| 482 | private void focused( | |
| 483 | final ObservableValue<? extends Boolean> ignored, | |
| 484 | final Boolean o, | |
| 485 | final Boolean n ) { | |
| 486 | if( n != null && n ) { | |
| 487 | TextDefinitionFocusEvent.fire( this ); | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | /** | |
| 492 | * Adds a menu item to a list of menu items. | |
| 493 | * | |
| 494 | * @param items The list of menu items to append to. | |
| 495 | * @param labelKey The resource bundle key name for the menu item's label. | |
| 496 | * @return The menu item added to the list of menu items. | |
| 497 | */ | |
| 498 | private MenuItem addMenuItem( | |
| 499 | final List<MenuItem> items, final String labelKey ) { | |
| 500 | final MenuItem menuItem = createMenuItem( labelKey ); | |
| 501 | items.add( menuItem ); | |
| 502 | return menuItem; | |
| 503 | } | |
| 504 | ||
| 505 | private MenuItem createMenuItem( final String labelKey ) { | |
| 506 | return new MenuItem( get( labelKey ) ); | |
| 507 | } | |
| 508 | ||
| 509 | /** | |
| 510 | * Creates a new {@link TreeItem} that is intended to be the root-level item | |
| 511 | * added to the {@link TreeView}. This allows the root item to be | |
| 512 | * distinguished from the other items so that reference keys do not include | |
| 513 | * "Definition" as part of their name. | |
| 514 | * | |
| 515 | * @return A new {@link TreeItem}, never {@code null}. | |
| 516 | */ | |
| 517 | private RootTreeItem<String> createRootTreeItem() { | |
| 518 | return new RootTreeItem<>( get( "Pane.definition.node.root.title" ) ); | |
| 519 | } | |
| 520 | ||
| 521 | private DefinitionTreeItem<String> createDefinitionTreeItem() { | |
| 522 | return new DefinitionTreeItem<>( get( "Definition.menu.add.default" ) ); | |
| 523 | } | |
| 524 | ||
| 525 | @Override | |
| 526 | public void requestFocus() { | |
| 527 | getTreeView().requestFocus(); | |
| 528 | } | |
| 529 | ||
| 530 | /** | |
| 531 | * Expands the node to the root, recursively. | |
| 532 | * | |
| 533 | * @param <T> The type of tree item to expand (usually String). | |
| 534 | * @param node The node to expand. | |
| 535 | */ | |
| 536 | @Override | |
| 537 | public <T> void expand( final TreeItem<T> node ) { | |
| 538 | if( node != null ) { | |
| 539 | expand( node.getParent() ); | |
| 540 | node.setExpanded( !node.isLeaf() ); | |
| 541 | } | |
| 542 | } | |
| 543 | ||
| 544 | /** | |
| 545 | * Answers whether there are any definitions in the tree. | |
| 546 | * | |
| 547 | * @return {@code true} when there are no definitions; {@code false} when | |
| 548 | * there's at least one definition. | |
| 549 | */ | |
| 550 | @Override | |
| 551 | public boolean isEmpty() { | |
| 552 | return getTreeRoot().isEmpty(); | |
| 553 | } | |
| 554 | ||
| 555 | /** | |
| 556 | * Returns the actively selected item in the tree. | |
| 557 | * | |
| 558 | * @return The selected item, or the tree root item if no item is selected. | |
| 559 | */ | |
| 560 | public TreeItem<String> getSelectedItem() { | |
| 561 | final var item = getSelectionModel().getSelectedItem(); | |
| 562 | return item == null ? getTreeRoot() : item; | |
| 563 | } | |
| 564 | ||
| 565 | /** | |
| 566 | * Returns the {@link TreeView} that contains the definition hierarchy. | |
| 567 | * | |
| 568 | * @return A non-null instance. | |
| 569 | */ | |
| 570 | private TreeView<String> getTreeView() { | |
| 571 | return mTreeView; | |
| 572 | } | |
| 573 | ||
| 574 | /** | |
| 575 | * Returns the root of the tree. | |
| 576 | * | |
| 577 | * @return The first node added to the definition tree. | |
| 578 | */ | |
| 579 | private DefinitionTreeItem<String> getTreeRoot() { | |
| 580 | return mTreeRoot; | |
| 581 | } | |
| 582 | ||
| 583 | private ObservableList<TreeItem<String>> getSiblings( | |
| 584 | final TreeItem<String> item ) { | |
| 585 | final var root = getTreeView().getRoot(); | |
| 586 | final var parent = (item == null || item == root) | |
| 587 | ? root | |
| 588 | : item.getParent(); | |
| 585 | 589 | |
| 586 | 590 | return parent.getChildren(); |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.editors.markdown; | |
| 6 | ||
| 7 | import com.vladsch.flexmark.ast.Link; | |
| 8 | ||
| 9 | /** | |
| 10 | * Represents the model for a hyperlink: text, url, and title. | |
| 11 | */ | |
| 12 | public final class HyperlinkModel { | |
| 13 | ||
| 14 | private String text; | |
| 15 | private String url; | |
| 16 | private String title; | |
| 17 | ||
| 18 | /** | |
| 19 | * Constructs a new hyperlink model in Markdown format by default with no | |
| 20 | * title (i.e., tooltip). | |
| 21 | * | |
| 22 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 23 | * @param url The destination URL (e.g., when clicked). | |
| 24 | */ | |
| 25 | public HyperlinkModel( final String text, final String url ) { | |
| 26 | this( text, url, null ); | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * Constructs a new hyperlink model for the given AST link. | |
| 31 | * | |
| 32 | * @param link A Markdown link. | |
| 33 | */ | |
| 34 | public HyperlinkModel( final Link link ) { | |
| 35 | this( | |
| 36 | link.getText().toString(), | |
| 37 | link.getUrl().toString(), | |
| 38 | link.getTitle().toString() | |
| 39 | ); | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Constructs a new hyperlink model in Markdown format by default. | |
| 44 | * | |
| 45 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 46 | * @param url The destination URL (e.g., when clicked). | |
| 47 | * @param title The hyperlink title (e.g., shown as a tooltip). | |
| 48 | */ | |
| 49 | public HyperlinkModel( | |
| 50 | final String text, final String url, final String title ) { | |
| 51 | setText( text ); | |
| 52 | setUrl( url ); | |
| 53 | setTitle( title ); | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Returns the string in Markdown format by default. | |
| 58 | * | |
| 59 | * @return A Markdown version of the hyperlink. | |
| 60 | */ | |
| 61 | @Override | |
| 62 | public String toString() { | |
| 63 | String format = "%s%s%s"; | |
| 64 | ||
| 65 | if( hasText() ) { | |
| 66 | format = "[%s]" + (hasTitle() ? "(%s \"%s\")" : "(%s%s)"); | |
| 67 | } | |
| 68 | ||
| 69 | // Becomes ""+URL+"" if no text is set. | |
| 70 | // Becomes [TITLE]+(URL)+"" if no title is set. | |
| 71 | // Becomes [TITLE]+(URL+ \"TITLE\") if title is set. | |
| 72 | return String.format( format, getText(), getUrl(), getTitle() ); | |
| 73 | } | |
| 74 | ||
| 75 | public void setText( final String text ) { | |
| 76 | this.text = sanitize( text ); | |
| 77 | } | |
| 78 | ||
| 79 | public void setUrl( final String url ) { | |
| 80 | this.url = sanitize( url ); | |
| 81 | } | |
| 82 | ||
| 83 | public void setTitle( final String title ) { | |
| 84 | this.title = sanitize( title ); | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Answers whether text has been set for the hyperlink. | |
| 89 | * | |
| 90 | * @return true This is a text link. | |
| 91 | */ | |
| 92 | public boolean hasText() { | |
| 93 | return !getText().isEmpty(); | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Answers whether a title (tooltip) has been set for the hyperlink. | |
| 98 | * | |
| 99 | * @return true There is a title. | |
| 100 | */ | |
| 101 | public boolean hasTitle() { | |
| 102 | return !getTitle().isEmpty(); | |
| 103 | } | |
| 104 | ||
| 105 | public String getText() { | |
| 106 | return this.text; | |
| 107 | } | |
| 108 | ||
| 109 | public String getUrl() { | |
| 110 | return this.url; | |
| 111 | } | |
| 112 | ||
| 113 | public String getTitle() { | |
| 114 | return this.title; | |
| 115 | } | |
| 116 | ||
| 117 | private String sanitize( final String s ) { | |
| 118 | return s == null ? "" : s; | |
| 119 | } | |
| 120 | } | |
| 121 | 1 |
| 43 | 43 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; |
| 44 | 44 | import static com.keenwrite.preferences.AppKeys.*; |
| 45 | import static java.lang.Character.isWhitespace; | |
| 46 | import static java.lang.String.format; | |
| 47 | import static java.util.Collections.singletonList; | |
| 48 | import static javafx.application.Platform.runLater; | |
| 49 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 50 | import static javafx.scene.input.KeyCode.*; | |
| 51 | import static javafx.scene.input.KeyCombination.*; | |
| 52 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 53 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 54 | import static org.fxmisc.richtext.Caret.CaretVisibility.ON; | |
| 55 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 56 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 57 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 58 | ||
| 59 | /** | |
| 60 | * Responsible for editing Markdown documents. | |
| 61 | */ | |
| 62 | public final class MarkdownEditor extends BorderPane implements TextEditor { | |
| 63 | /** | |
| 64 | * Regular expression that matches the type of markup block. This is used | |
| 65 | * when Enter is pressed to continue the block environment. | |
| 66 | */ | |
| 67 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 68 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 69 | ||
| 70 | private final Workspace mWorkspace; | |
| 71 | ||
| 72 | /** | |
| 73 | * The text editor. | |
| 74 | */ | |
| 75 | private final StyleClassedTextArea mTextArea = | |
| 76 | new StyleClassedTextArea( false ); | |
| 77 | ||
| 78 | /** | |
| 79 | * Wraps the text editor in scrollbars. | |
| 80 | */ | |
| 81 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 82 | new VirtualizedScrollPane<>( mTextArea ); | |
| 83 | ||
| 84 | /** | |
| 85 | * Tracks where the caret is located in this document. This offers observable | |
| 86 | * properties for caret position changes. | |
| 87 | */ | |
| 88 | private final Caret mCaret = createCaret( mTextArea ); | |
| 89 | ||
| 90 | /** | |
| 91 | * File being edited by this editor instance. | |
| 92 | */ | |
| 93 | private File mFile; | |
| 94 | ||
| 95 | /** | |
| 96 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 97 | * false} by default. | |
| 98 | */ | |
| 99 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 100 | ||
| 101 | /** | |
| 102 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 103 | * either no encoding could be determined or this is a new (empty) file. | |
| 104 | */ | |
| 105 | private final Charset mEncoding; | |
| 106 | ||
| 107 | /** | |
| 108 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 109 | * persisted definitions. | |
| 110 | */ | |
| 111 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 112 | ||
| 113 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 114 | mEncoding = open( mFile = file ); | |
| 115 | mWorkspace = workspace; | |
| 116 | ||
| 117 | initTextArea( mTextArea ); | |
| 118 | initStyle( mTextArea ); | |
| 119 | initScrollPane( mScrollPane ); | |
| 120 | initHotKeys(); | |
| 121 | initUndoManager(); | |
| 122 | } | |
| 123 | ||
| 124 | @SuppressWarnings( "unused" ) | |
| 125 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 126 | textArea.setShowCaret( ON ); | |
| 127 | textArea.setWrapText( true ); | |
| 128 | textArea.requestFollowCaret(); | |
| 129 | textArea.moveTo( 0 ); | |
| 130 | ||
| 131 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 132 | // Fire, regardless of whether the caret position has changed. | |
| 133 | mDirty.set( false ); | |
| 134 | ||
| 135 | // Prevent the subsequent caret position change from raising dirty bits. | |
| 136 | mDirty.set( true ); | |
| 137 | } ); | |
| 138 | ||
| 139 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 140 | // Fire when the caret position has changed and the text has not. | |
| 141 | mDirty.set( true ); | |
| 142 | mDirty.set( false ); | |
| 143 | } ); | |
| 144 | ||
| 145 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 146 | if( n != null && n ) { | |
| 147 | TextEditorFocusEvent.fire( this ); | |
| 148 | } | |
| 149 | } ); | |
| 150 | } | |
| 151 | ||
| 152 | @SuppressWarnings( "unused" ) | |
| 153 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 154 | textArea.getStyleClass().add( "markdown" ); | |
| 155 | ||
| 156 | final var stylesheets = textArea.getStylesheets(); | |
| 157 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 158 | ||
| 159 | localeProperty().addListener( ( c, o, n ) -> { | |
| 160 | if( n != null ) { | |
| 161 | stylesheets.clear(); | |
| 162 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 163 | } | |
| 164 | } ); | |
| 165 | ||
| 166 | fontNameProperty().addListener( | |
| 167 | ( c, o, n ) -> | |
| 168 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 169 | ); | |
| 170 | ||
| 171 | fontSizeProperty().addListener( | |
| 172 | ( c, o, n ) -> | |
| 173 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 174 | ); | |
| 175 | ||
| 176 | setFont( mTextArea, getFontName(), getFontSize() ); | |
| 177 | } | |
| 178 | ||
| 179 | private void initScrollPane( | |
| 180 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 181 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 182 | setCenter( scrollpane ); | |
| 183 | } | |
| 184 | ||
| 185 | private void initHotKeys() { | |
| 186 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 187 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 188 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 189 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 190 | } | |
| 191 | ||
| 192 | private void initUndoManager() { | |
| 193 | final var undoManager = getUndoManager(); | |
| 194 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 195 | ||
| 196 | undoManager.forgetHistory(); | |
| 197 | undoManager.mark(); | |
| 198 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 199 | } | |
| 200 | ||
| 201 | @Override | |
| 202 | public void moveTo( final int offset ) { | |
| 203 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 204 | ||
| 205 | if( offset <= mTextArea.getLength() ) { | |
| 206 | mTextArea.moveTo( offset ); | |
| 207 | mTextArea.requestFollowCaret(); | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Delegate the focus request to the text area itself. | |
| 213 | */ | |
| 214 | @Override | |
| 215 | public void requestFocus() { | |
| 216 | mTextArea.requestFocus(); | |
| 217 | } | |
| 218 | ||
| 219 | @Override | |
| 220 | public void setText( final String text ) { | |
| 221 | mTextArea.clear(); | |
| 222 | mTextArea.appendText( text ); | |
| 223 | mTextArea.getUndoManager().mark(); | |
| 224 | } | |
| 225 | ||
| 226 | @Override | |
| 227 | public String getText() { | |
| 228 | return mTextArea.getText(); | |
| 229 | } | |
| 230 | ||
| 231 | @Override | |
| 232 | public Charset getEncoding() { | |
| 233 | return mEncoding; | |
| 234 | } | |
| 235 | ||
| 236 | @Override | |
| 237 | public File getFile() { | |
| 238 | return mFile; | |
| 239 | } | |
| 240 | ||
| 241 | @Override | |
| 242 | public void rename( final File file ) { | |
| 243 | mFile = file; | |
| 244 | } | |
| 245 | ||
| 246 | @Override | |
| 247 | public void undo() { | |
| 248 | final var manager = getUndoManager(); | |
| 249 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 250 | } | |
| 251 | ||
| 252 | @Override | |
| 253 | public void redo() { | |
| 254 | final var manager = getUndoManager(); | |
| 255 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 260 | * message to the user. | |
| 261 | * | |
| 262 | * @param ready Answers whether the action can be executed. | |
| 263 | * @param action The action to execute. | |
| 264 | * @param key The informational message key having a value to display if | |
| 265 | * the {@link Supplier} is not ready. | |
| 266 | */ | |
| 267 | private void xxdo( | |
| 268 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 269 | if( ready.get() ) { | |
| 270 | action.run(); | |
| 271 | } | |
| 272 | else { | |
| 273 | clue( key ); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | @Override | |
| 278 | public void cut() { | |
| 279 | final var selected = mTextArea.getSelectedText(); | |
| 280 | ||
| 281 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 282 | if( selected == null || selected.isEmpty() ) { | |
| 283 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 284 | mTextArea.fireEvent( keyDown( HOME, false ) ); | |
| 285 | mTextArea.fireEvent( keyDown( DOWN, true ) ); | |
| 286 | } | |
| 287 | ||
| 288 | mTextArea.cut(); | |
| 289 | } | |
| 290 | ||
| 291 | @Override | |
| 292 | public void copy() { | |
| 293 | mTextArea.copy(); | |
| 294 | } | |
| 295 | ||
| 296 | @Override | |
| 297 | public void paste() { | |
| 298 | mTextArea.paste(); | |
| 299 | } | |
| 300 | ||
| 301 | @Override | |
| 302 | public void selectAll() { | |
| 303 | mTextArea.selectAll(); | |
| 304 | } | |
| 305 | ||
| 306 | @Override | |
| 307 | public void bold() { | |
| 308 | enwrap( "**" ); | |
| 309 | } | |
| 310 | ||
| 311 | @Override | |
| 312 | public void italic() { | |
| 313 | enwrap( "*" ); | |
| 314 | } | |
| 315 | ||
| 316 | @Override | |
| 317 | public void monospace() { | |
| 318 | enwrap( "`" ); | |
| 319 | } | |
| 320 | ||
| 321 | @Override | |
| 322 | public void superscript() { | |
| 323 | enwrap( "^" ); | |
| 324 | } | |
| 325 | ||
| 326 | @Override | |
| 327 | public void subscript() { | |
| 328 | enwrap( "~" ); | |
| 329 | } | |
| 330 | ||
| 331 | @Override | |
| 332 | public void strikethrough() { | |
| 333 | enwrap( "~~" ); | |
| 334 | } | |
| 335 | ||
| 336 | @Override | |
| 337 | public void blockquote() { | |
| 338 | block( "> " ); | |
| 339 | } | |
| 340 | ||
| 341 | @Override | |
| 342 | public void code() { | |
| 343 | enwrap( "`" ); | |
| 344 | } | |
| 345 | ||
| 346 | @Override | |
| 347 | public void fencedCodeBlock() { | |
| 348 | enwrap( "\n\n```\n", "\n```\n\n" ); | |
| 349 | } | |
| 350 | ||
| 351 | @Override | |
| 352 | public void heading( final int level ) { | |
| 353 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 354 | block( format( "%s ", hashes ) ); | |
| 355 | } | |
| 356 | ||
| 357 | @Override | |
| 358 | public void unorderedList() { | |
| 359 | block( "* " ); | |
| 360 | } | |
| 361 | ||
| 362 | @Override | |
| 363 | public void orderedList() { | |
| 364 | block( "1. " ); | |
| 365 | } | |
| 366 | ||
| 367 | @Override | |
| 368 | public void horizontalRule() { | |
| 369 | block( format( "---%n%n" ) ); | |
| 370 | } | |
| 371 | ||
| 372 | @Override | |
| 373 | public Node getNode() { | |
| 374 | return this; | |
| 375 | } | |
| 376 | ||
| 377 | @Override | |
| 378 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 379 | return mModified; | |
| 380 | } | |
| 381 | ||
| 382 | @Override | |
| 383 | public void clearModifiedProperty() { | |
| 384 | getUndoManager().mark(); | |
| 385 | } | |
| 386 | ||
| 387 | @Override | |
| 388 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 389 | return mScrollPane; | |
| 390 | } | |
| 391 | ||
| 392 | @Override | |
| 393 | public StyleClassedTextArea getTextArea() { | |
| 394 | return mTextArea; | |
| 395 | } | |
| 396 | ||
| 397 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 398 | ||
| 399 | @Override | |
| 400 | public void stylize( final IndexRange range, final String style ) { | |
| 401 | final var began = range.getStart(); | |
| 402 | final var ended = range.getEnd() + 1; | |
| 403 | ||
| 404 | assert 0 <= began && began <= ended; | |
| 405 | assert style != null; | |
| 406 | ||
| 407 | // TODO: Ensure spell check and find highlights can coexist. | |
| 408 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 409 | // System.out.println( "SPANS: " + spans ); | |
| 410 | ||
| 411 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 412 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 413 | // ) ); | |
| 414 | ||
| 415 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 416 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 417 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 418 | ||
| 419 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 420 | // System.out.println( "STYLES: " +s ); | |
| 421 | ||
| 422 | mStyles.put( style, range ); | |
| 423 | mTextArea.setStyleClass( began, ended, style ); | |
| 424 | ||
| 425 | // Ensure that whenever the user interacts with the text that the found | |
| 426 | // word will have its highlighting removed. The handler removes itself. | |
| 427 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 428 | final var handler = mTextArea.getOnKeyPressed(); | |
| 429 | mTextArea.setOnKeyPressed( event -> { | |
| 430 | mTextArea.setOnKeyPressed( handler ); | |
| 431 | unstylize( style ); | |
| 432 | } ); | |
| 433 | ||
| 434 | //mTextArea.setStyleSpans(began, ended, s); | |
| 435 | } | |
| 436 | ||
| 437 | private static StyleSpans<Collection<String>> merge( | |
| 438 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 439 | spans = spans.overlay( | |
| 440 | singleton( singletonList( style ), len ), | |
| 441 | ( bottomSpan, list ) -> { | |
| 442 | final List<String> l = | |
| 443 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 444 | l.addAll( bottomSpan ); | |
| 445 | l.addAll( list ); | |
| 446 | return l; | |
| 447 | } ); | |
| 448 | ||
| 449 | return spans; | |
| 450 | } | |
| 451 | ||
| 452 | @Override | |
| 453 | public void unstylize( final String style ) { | |
| 454 | final var indexes = mStyles.remove( style ); | |
| 455 | if( indexes != null ) { | |
| 456 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | @Override | |
| 461 | public Caret getCaret() { | |
| 462 | return mCaret; | |
| 463 | } | |
| 464 | ||
| 465 | /** | |
| 466 | * A {@link Caret} instance is not directly coupled ot the GUI because | |
| 467 | * document processing does not always require interactive status bar | |
| 468 | * updates. This can happen when processing from the command-line. However, | |
| 469 | * the processors need the {@link Caret} instance to inject the caret | |
| 470 | * position into the document. Making the {@link CaretExtension} optional | |
| 471 | * would require more effort than using a {@link Caret} model that is | |
| 472 | * decoupled from GUI widgets. | |
| 473 | * | |
| 474 | * @param editor The text editor containing caret position information. | |
| 475 | * @return An instance of {@link Caret} that tracks the GUI caret position. | |
| 476 | */ | |
| 477 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 478 | return Caret | |
| 479 | .builder() | |
| 480 | .with( Caret.Mutator::setParagraph, | |
| 481 | () -> editor.currentParagraphProperty().getValue() ) | |
| 482 | .with( Caret.Mutator::setParagraphs, | |
| 483 | () -> editor.getParagraphs().size() ) | |
| 484 | .with( Caret.Mutator::setParaOffset, | |
| 485 | () -> editor.caretColumnProperty().getValue() ) | |
| 486 | .with( Caret.Mutator::setTextOffset, | |
| 487 | () -> editor.caretPositionProperty().getValue() ) | |
| 488 | .with( Caret.Mutator::setTextLength, | |
| 489 | () -> editor.lengthProperty().getValue() ) | |
| 490 | .build(); | |
| 491 | } | |
| 492 | ||
| 493 | /** | |
| 494 | * This method adds listeners to editor events. | |
| 495 | * | |
| 496 | * @param <T> The event type. | |
| 497 | * @param <U> The consumer type for the given event type. | |
| 498 | * @param event The event of interest. | |
| 499 | * @param consumer The method to call when the event happens. | |
| 500 | */ | |
| 501 | public <T extends Event, U extends T> void addEventListener( | |
| 502 | final EventPattern<? super T, ? extends U> event, | |
| 503 | final Consumer<? super U> consumer ) { | |
| 504 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 505 | } | |
| 506 | ||
| 507 | private void onEnterPressed( final KeyEvent ignored ) { | |
| 508 | final var currentLine = getCaretParagraph(); | |
| 509 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 510 | ||
| 511 | // By default, insert a new line by itself. | |
| 512 | String newText = NEWLINE; | |
| 513 | ||
| 514 | // If the pattern was matched then determine what block type to continue. | |
| 515 | if( matcher.matches() ) { | |
| 516 | if( matcher.group( 2 ).isEmpty() ) { | |
| 517 | final var pos = mTextArea.getCaretPosition(); | |
| 518 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 519 | } | |
| 520 | else { | |
| 521 | // Indent the new line with the same whitespace characters and | |
| 522 | // list markers as current line. This ensures that the indentation | |
| 523 | // is propagated. | |
| 524 | newText = newText.concat( matcher.group( 1 ) ); | |
| 525 | } | |
| 526 | } | |
| 527 | ||
| 528 | mTextArea.replaceSelection( newText ); | |
| 529 | mTextArea.requestFollowCaret(); | |
| 530 | } | |
| 531 | ||
| 532 | private void cut( final KeyEvent event ) { | |
| 533 | cut(); | |
| 534 | } | |
| 535 | ||
| 536 | private void tab( final KeyEvent event ) { | |
| 537 | final var range = mTextArea.selectionProperty().getValue(); | |
| 538 | final var sb = new StringBuilder( 1024 ); | |
| 539 | ||
| 540 | if( range.getLength() > 0 ) { | |
| 541 | final var selection = mTextArea.getSelectedText(); | |
| 542 | ||
| 543 | selection.lines().forEach( | |
| 544 | l -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 545 | ); | |
| 546 | } | |
| 547 | else { | |
| 548 | sb.append( "\t" ); | |
| 549 | } | |
| 550 | ||
| 551 | mTextArea.replaceSelection( sb.toString() ); | |
| 552 | } | |
| 553 | ||
| 554 | private void untab( final KeyEvent event ) { | |
| 555 | final var range = mTextArea.selectionProperty().getValue(); | |
| 556 | ||
| 557 | if( range.getLength() > 0 ) { | |
| 558 | final var selection = mTextArea.getSelectedText(); | |
| 559 | final var sb = new StringBuilder( selection.length() ); | |
| 560 | ||
| 561 | selection.lines().forEach( | |
| 562 | l -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 563 | .append( NEWLINE ) | |
| 564 | ); | |
| 565 | ||
| 566 | mTextArea.replaceSelection( sb.toString() ); | |
| 567 | } | |
| 568 | else { | |
| 569 | final var p = getCaretParagraph(); | |
| 570 | ||
| 571 | if( p.startsWith( "\t" ) ) { | |
| 572 | mTextArea.selectParagraph(); | |
| 573 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 574 | } | |
| 575 | } | |
| 576 | } | |
| 577 | ||
| 578 | /** | |
| 579 | * Observers may listen for changes to the property returned from this method | |
| 580 | * to receive notifications when either the text or caret have changed. This | |
| 581 | * should not be used to track whether the text has been modified. | |
| 582 | */ | |
| 583 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 584 | mDirty.addListener( listener ); | |
| 585 | } | |
| 586 | ||
| 587 | /** | |
| 588 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 589 | * | |
| 590 | * @param token The beginning and ending token for enclosing the text. | |
| 591 | */ | |
| 592 | private void enwrap( final String token ) { | |
| 593 | enwrap( token, token ); | |
| 594 | } | |
| 595 | ||
| 596 | /** | |
| 597 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 598 | * | |
| 599 | * @param began The beginning token for enclosing the text. | |
| 600 | * @param ended The ending token for enclosing the text. | |
| 601 | */ | |
| 602 | private void enwrap( final String began, String ended ) { | |
| 603 | // Ensure selected text takes precedence over the word at caret position. | |
| 604 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 605 | final var range = selected.getLength() == 0 | |
| 606 | ? getCaretWord() | |
| 607 | : selected; | |
| 608 | String text = mTextArea.getText( range ); | |
| 609 | ||
| 610 | int length = range.getLength(); | |
| 611 | text = stripStart( text, null ); | |
| 612 | final int beganIndex = range.getStart() + length - text.length(); | |
| 613 | ||
| 614 | length = text.length(); | |
| 615 | text = stripEnd( text, null ); | |
| 616 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 617 | ||
| 618 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 619 | } | |
| 620 | ||
| 621 | /** | |
| 622 | * Inserts the given block-level markup at the current caret position | |
| 623 | * within the document. This will prepend two blank lines to ensure that | |
| 624 | * the block element begins at the start of a new line. | |
| 625 | * | |
| 626 | * @param markup The text to insert at the caret. | |
| 627 | */ | |
| 628 | private void block( final String markup ) { | |
| 629 | final int pos = mTextArea.getCaretPosition(); | |
| 630 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 631 | } | |
| 632 | ||
| 633 | /** | |
| 634 | * Returns the caret position within the current paragraph. | |
| 635 | * | |
| 636 | * @return A value from 0 to the length of the current paragraph. | |
| 637 | */ | |
| 638 | private int getCaretColumn() { | |
| 639 | return mTextArea.getCaretColumn(); | |
| 640 | } | |
| 641 | ||
| 642 | @Override | |
| 643 | public IndexRange getCaretWord() { | |
| 644 | final var paragraph = getCaretParagraph() | |
| 645 | .replaceAll( "---", " " ) | |
| 646 | .replaceAll( "--", " " ) | |
| 647 | .replaceAll( "[\\[\\]{}()]", " " ); | |
| 648 | final var length = paragraph.length(); | |
| 649 | final var column = getCaretColumn(); | |
| 650 | ||
| 651 | var began = column; | |
| 652 | var ended = column; | |
| 653 | ||
| 654 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 655 | began--; | |
| 656 | } | |
| 657 | ||
| 658 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 659 | ended++; | |
| 660 | } | |
| 661 | ||
| 662 | final var iterator = BreakIterator.getWordInstance(); | |
| 663 | iterator.setText( paragraph ); | |
| 664 | ||
| 665 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 666 | began++; | |
| 667 | } | |
| 668 | ||
| 669 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 670 | ended--; | |
| 671 | } | |
| 672 | ||
| 673 | final var offset = getCaretDocumentOffset( column ); | |
| 674 | ||
| 675 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 676 | } | |
| 677 | ||
| 678 | private int getCaretDocumentOffset( final int column ) { | |
| 679 | return mTextArea.getCaretPosition() - column; | |
| 680 | } | |
| 681 | ||
| 682 | /** | |
| 683 | * Returns the index of the paragraph where the caret resides. | |
| 684 | * | |
| 685 | * @return A number greater than or equal to 0. | |
| 686 | */ | |
| 687 | private int getCurrentParagraph() { | |
| 688 | return mTextArea.getCurrentParagraph(); | |
| 689 | } | |
| 690 | ||
| 691 | /** | |
| 692 | * Returns the text for the paragraph that contains the caret. | |
| 693 | * | |
| 694 | * @return A non-null string, possibly empty. | |
| 695 | */ | |
| 696 | private String getCaretParagraph() { | |
| 697 | return getText( getCurrentParagraph() ); | |
| 698 | } | |
| 699 | ||
| 700 | @Override | |
| 701 | public String getText( final int paragraph ) { | |
| 702 | return mTextArea.getText( paragraph ); | |
| 703 | } | |
| 704 | ||
| 705 | @Override | |
| 706 | public String getText( final IndexRange indexes ) | |
| 707 | throws IndexOutOfBoundsException { | |
| 708 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 709 | } | |
| 710 | ||
| 711 | @Override | |
| 712 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 713 | mTextArea.replaceText( indexes, s ); | |
| 714 | } | |
| 715 | ||
| 716 | private UndoManager<?> getUndoManager() { | |
| 717 | return mTextArea.getUndoManager(); | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 722 | * | |
| 723 | * @return A non-null string to inject into the HTML document head. | |
| 724 | */ | |
| 725 | private static String getStylesheetPath( final Locale locale ) { | |
| 726 | return MessageFormat.format( | |
| 727 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | |
| 728 | locale.getLanguage(), | |
| 729 | locale.getScript(), | |
| 730 | locale.getCountry() | |
| 731 | ); | |
| 732 | } | |
| 733 | ||
| 734 | private Locale getLocale() { | |
| 735 | return localeProperty().toLocale(); | |
| 736 | } | |
| 737 | ||
| 738 | private LocaleProperty localeProperty() { | |
| 739 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 740 | } | |
| 741 | ||
| 742 | /** | |
| 743 | * Sets the font family name and font size at the same time. When the | |
| 744 | * workspace is loaded, the default font values are changed, which results | |
| 745 | * in this method being called. | |
| 746 | * | |
| 747 | * @param area Change the font settings for this text area. | |
| 748 | * @param name New font family name to apply. | |
| 749 | * @param points New font size to apply (in points, not pixels). | |
| 750 | */ | |
| 751 | private void setFont( | |
| 752 | final StyleClassedTextArea area, final String name, final double points ) { | |
| 753 | runLater( () -> area.setStyle( | |
| 754 | format( | |
| 755 | "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points ) | |
| 756 | ) | |
| 757 | ) ); | |
| 758 | } | |
| 759 | ||
| 760 | private String getFontName() { | |
| 761 | return fontNameProperty().get(); | |
| 762 | } | |
| 763 | ||
| 764 | private StringProperty fontNameProperty() { | |
| 765 | return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME ); | |
| 766 | } | |
| 767 | ||
| 768 | private double getFontSize() { | |
| 769 | return fontSizeProperty().get(); | |
| 770 | } | |
| 771 | ||
| 772 | private DoubleProperty fontSizeProperty() { | |
| 773 | return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE ); | |
| 774 | } | |
| 775 | ||
| 776 | /** | |
| 777 | * Answers whether the given resource is of compatible {@link MediaType}s. | |
| 778 | * | |
| 779 | * @param mediaType The {@link MediaType} to compare. | |
| 780 | * @return {@code true} if the given {@link MediaType} is suitable for | |
| 781 | * editing with this type of editor. | |
| 782 | */ | |
| 783 | @Override | |
| 784 | public boolean supports( final MediaType mediaType ) { | |
| 785 | return isMediaType( mediaType ) || | |
| 786 | mediaType == TEXT_MARKDOWN || | |
| 787 | mediaType == TEXT_R_MARKDOWN; | |
| 45 | import static com.keenwrite.util.Strings.trimEnd; | |
| 46 | import static com.keenwrite.util.Strings.trimStart; | |
| 47 | import static java.lang.Character.isWhitespace; | |
| 48 | import static java.lang.String.format; | |
| 49 | import static java.util.Collections.singletonList; | |
| 50 | import static javafx.application.Platform.runLater; | |
| 51 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 52 | import static javafx.scene.input.KeyCode.*; | |
| 53 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 54 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 55 | import static org.fxmisc.richtext.Caret.CaretVisibility.ON; | |
| 56 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 57 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 58 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 59 | ||
| 60 | /** | |
| 61 | * Responsible for editing Markdown documents. | |
| 62 | */ | |
| 63 | public final class MarkdownEditor extends BorderPane implements TextEditor { | |
| 64 | /** | |
| 65 | * Represents a failed index search. | |
| 66 | */ | |
| 67 | private static final int INDEX_NOT_FOUND = -1; | |
| 68 | ||
| 69 | /** | |
| 70 | * Regular expression that matches the type of markup block. This is used | |
| 71 | * when Enter is pressed to continue the block environment. | |
| 72 | */ | |
| 73 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 74 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 75 | ||
| 76 | private final Workspace mWorkspace; | |
| 77 | ||
| 78 | /** | |
| 79 | * The text editor. | |
| 80 | */ | |
| 81 | private final StyleClassedTextArea mTextArea = | |
| 82 | new StyleClassedTextArea( false ); | |
| 83 | ||
| 84 | /** | |
| 85 | * Wraps the text editor in scrollbars. | |
| 86 | */ | |
| 87 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 88 | new VirtualizedScrollPane<>( mTextArea ); | |
| 89 | ||
| 90 | /** | |
| 91 | * Tracks where the caret is located in this document. This offers observable | |
| 92 | * properties for caret position changes. | |
| 93 | */ | |
| 94 | private final Caret mCaret = createCaret( mTextArea ); | |
| 95 | ||
| 96 | /** | |
| 97 | * File being edited by this editor instance. | |
| 98 | */ | |
| 99 | private File mFile; | |
| 100 | ||
| 101 | /** | |
| 102 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 103 | * false} by default. | |
| 104 | */ | |
| 105 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 106 | ||
| 107 | /** | |
| 108 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 109 | * either no encoding could be determined or this is a new (empty) file. | |
| 110 | */ | |
| 111 | private final Charset mEncoding; | |
| 112 | ||
| 113 | /** | |
| 114 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 115 | * persisted definitions. | |
| 116 | */ | |
| 117 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 118 | ||
| 119 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 120 | mEncoding = open( mFile = file ); | |
| 121 | mWorkspace = workspace; | |
| 122 | ||
| 123 | initTextArea( mTextArea ); | |
| 124 | initStyle( mTextArea ); | |
| 125 | initScrollPane( mScrollPane ); | |
| 126 | initHotKeys(); | |
| 127 | initUndoManager(); | |
| 128 | } | |
| 129 | ||
| 130 | @SuppressWarnings( "unused" ) | |
| 131 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 132 | textArea.setShowCaret( ON ); | |
| 133 | textArea.setWrapText( true ); | |
| 134 | textArea.requestFollowCaret(); | |
| 135 | textArea.moveTo( 0 ); | |
| 136 | ||
| 137 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 138 | // Fire, regardless of whether the caret position has changed. | |
| 139 | mDirty.set( false ); | |
| 140 | ||
| 141 | // Prevent the subsequent caret position change from raising dirty bits. | |
| 142 | mDirty.set( true ); | |
| 143 | } ); | |
| 144 | ||
| 145 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 146 | // Fire when the caret position has changed and the text has not. | |
| 147 | mDirty.set( true ); | |
| 148 | mDirty.set( false ); | |
| 149 | } ); | |
| 150 | ||
| 151 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 152 | if( n != null && n ) { | |
| 153 | TextEditorFocusEvent.fire( this ); | |
| 154 | } | |
| 155 | } ); | |
| 156 | } | |
| 157 | ||
| 158 | @SuppressWarnings( "unused" ) | |
| 159 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 160 | textArea.getStyleClass().add( "markdown" ); | |
| 161 | ||
| 162 | final var stylesheets = textArea.getStylesheets(); | |
| 163 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 164 | ||
| 165 | localeProperty().addListener( ( c, o, n ) -> { | |
| 166 | if( n != null ) { | |
| 167 | stylesheets.clear(); | |
| 168 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 169 | } | |
| 170 | } ); | |
| 171 | ||
| 172 | fontNameProperty().addListener( | |
| 173 | ( c, o, n ) -> | |
| 174 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 175 | ); | |
| 176 | ||
| 177 | fontSizeProperty().addListener( | |
| 178 | ( c, o, n ) -> | |
| 179 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 180 | ); | |
| 181 | ||
| 182 | setFont( mTextArea, getFontName(), getFontSize() ); | |
| 183 | } | |
| 184 | ||
| 185 | private void initScrollPane( | |
| 186 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 187 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 188 | setCenter( scrollpane ); | |
| 189 | } | |
| 190 | ||
| 191 | private void initHotKeys() { | |
| 192 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 193 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 194 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 195 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 196 | } | |
| 197 | ||
| 198 | private void initUndoManager() { | |
| 199 | final var undoManager = getUndoManager(); | |
| 200 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 201 | ||
| 202 | undoManager.forgetHistory(); | |
| 203 | undoManager.mark(); | |
| 204 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 205 | } | |
| 206 | ||
| 207 | @Override | |
| 208 | public void moveTo( final int offset ) { | |
| 209 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 210 | ||
| 211 | if( offset <= mTextArea.getLength() ) { | |
| 212 | mTextArea.moveTo( offset ); | |
| 213 | mTextArea.requestFollowCaret(); | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * Delegate the focus request to the text area itself. | |
| 219 | */ | |
| 220 | @Override | |
| 221 | public void requestFocus() { | |
| 222 | mTextArea.requestFocus(); | |
| 223 | } | |
| 224 | ||
| 225 | @Override | |
| 226 | public void setText( final String text ) { | |
| 227 | mTextArea.clear(); | |
| 228 | mTextArea.appendText( text ); | |
| 229 | mTextArea.getUndoManager().mark(); | |
| 230 | } | |
| 231 | ||
| 232 | @Override | |
| 233 | public String getText() { | |
| 234 | return mTextArea.getText(); | |
| 235 | } | |
| 236 | ||
| 237 | @Override | |
| 238 | public Charset getEncoding() { | |
| 239 | return mEncoding; | |
| 240 | } | |
| 241 | ||
| 242 | @Override | |
| 243 | public File getFile() { | |
| 244 | return mFile; | |
| 245 | } | |
| 246 | ||
| 247 | @Override | |
| 248 | public void rename( final File file ) { | |
| 249 | mFile = file; | |
| 250 | } | |
| 251 | ||
| 252 | @Override | |
| 253 | public void undo() { | |
| 254 | final var manager = getUndoManager(); | |
| 255 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 256 | } | |
| 257 | ||
| 258 | @Override | |
| 259 | public void redo() { | |
| 260 | final var manager = getUndoManager(); | |
| 261 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 266 | * message to the user. | |
| 267 | * | |
| 268 | * @param ready Answers whether the action can be executed. | |
| 269 | * @param action The action to execute. | |
| 270 | * @param key The informational message key having a value to display if | |
| 271 | * the {@link Supplier} is not ready. | |
| 272 | */ | |
| 273 | private void xxdo( | |
| 274 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 275 | if( ready.get() ) { | |
| 276 | action.run(); | |
| 277 | } | |
| 278 | else { | |
| 279 | clue( key ); | |
| 280 | } | |
| 281 | } | |
| 282 | ||
| 283 | @Override | |
| 284 | public void cut() { | |
| 285 | final var selected = mTextArea.getSelectedText(); | |
| 286 | ||
| 287 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 288 | if( selected == null || selected.isEmpty() ) { | |
| 289 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 290 | mTextArea.fireEvent( keyDown( HOME, false ) ); | |
| 291 | mTextArea.fireEvent( keyDown( DOWN, true ) ); | |
| 292 | } | |
| 293 | ||
| 294 | mTextArea.cut(); | |
| 295 | } | |
| 296 | ||
| 297 | @Override | |
| 298 | public void copy() { | |
| 299 | mTextArea.copy(); | |
| 300 | } | |
| 301 | ||
| 302 | @Override | |
| 303 | public void paste() { | |
| 304 | mTextArea.paste(); | |
| 305 | } | |
| 306 | ||
| 307 | @Override | |
| 308 | public void selectAll() { | |
| 309 | mTextArea.selectAll(); | |
| 310 | } | |
| 311 | ||
| 312 | @Override | |
| 313 | public void bold() { | |
| 314 | enwrap( "**" ); | |
| 315 | } | |
| 316 | ||
| 317 | @Override | |
| 318 | public void italic() { | |
| 319 | enwrap( "*" ); | |
| 320 | } | |
| 321 | ||
| 322 | @Override | |
| 323 | public void monospace() { | |
| 324 | enwrap( "`" ); | |
| 325 | } | |
| 326 | ||
| 327 | @Override | |
| 328 | public void superscript() { | |
| 329 | enwrap( "^" ); | |
| 330 | } | |
| 331 | ||
| 332 | @Override | |
| 333 | public void subscript() { | |
| 334 | enwrap( "~" ); | |
| 335 | } | |
| 336 | ||
| 337 | @Override | |
| 338 | public void strikethrough() { | |
| 339 | enwrap( "~~" ); | |
| 340 | } | |
| 341 | ||
| 342 | @Override | |
| 343 | public void blockquote() { | |
| 344 | block( "> " ); | |
| 345 | } | |
| 346 | ||
| 347 | @Override | |
| 348 | public void code() { | |
| 349 | enwrap( "`" ); | |
| 350 | } | |
| 351 | ||
| 352 | @Override | |
| 353 | public void fencedCodeBlock() { | |
| 354 | enwrap( "\n\n```\n", "\n```\n\n" ); | |
| 355 | } | |
| 356 | ||
| 357 | @Override | |
| 358 | public void heading( final int level ) { | |
| 359 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 360 | block( format( "%s ", hashes ) ); | |
| 361 | } | |
| 362 | ||
| 363 | @Override | |
| 364 | public void unorderedList() { | |
| 365 | block( "* " ); | |
| 366 | } | |
| 367 | ||
| 368 | @Override | |
| 369 | public void orderedList() { | |
| 370 | block( "1. " ); | |
| 371 | } | |
| 372 | ||
| 373 | @Override | |
| 374 | public void horizontalRule() { | |
| 375 | block( format( "---%n%n" ) ); | |
| 376 | } | |
| 377 | ||
| 378 | @Override | |
| 379 | public Node getNode() { | |
| 380 | return this; | |
| 381 | } | |
| 382 | ||
| 383 | @Override | |
| 384 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 385 | return mModified; | |
| 386 | } | |
| 387 | ||
| 388 | @Override | |
| 389 | public void clearModifiedProperty() { | |
| 390 | getUndoManager().mark(); | |
| 391 | } | |
| 392 | ||
| 393 | @Override | |
| 394 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 395 | return mScrollPane; | |
| 396 | } | |
| 397 | ||
| 398 | @Override | |
| 399 | public StyleClassedTextArea getTextArea() { | |
| 400 | return mTextArea; | |
| 401 | } | |
| 402 | ||
| 403 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 404 | ||
| 405 | @Override | |
| 406 | public void stylize( final IndexRange range, final String style ) { | |
| 407 | final var began = range.getStart(); | |
| 408 | final var ended = range.getEnd() + 1; | |
| 409 | ||
| 410 | assert 0 <= began && began <= ended; | |
| 411 | assert style != null; | |
| 412 | ||
| 413 | // TODO: Ensure spell check and find highlights can coexist. | |
| 414 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 415 | // System.out.println( "SPANS: " + spans ); | |
| 416 | ||
| 417 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 418 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 419 | // ) ); | |
| 420 | ||
| 421 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 422 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 423 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 424 | ||
| 425 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 426 | // System.out.println( "STYLES: " +s ); | |
| 427 | ||
| 428 | mStyles.put( style, range ); | |
| 429 | mTextArea.setStyleClass( began, ended, style ); | |
| 430 | ||
| 431 | // Ensure that whenever the user interacts with the text that the found | |
| 432 | // word will have its highlighting removed. The handler removes itself. | |
| 433 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 434 | final var handler = mTextArea.getOnKeyPressed(); | |
| 435 | mTextArea.setOnKeyPressed( event -> { | |
| 436 | mTextArea.setOnKeyPressed( handler ); | |
| 437 | unstylize( style ); | |
| 438 | } ); | |
| 439 | ||
| 440 | //mTextArea.setStyleSpans(began, ended, s); | |
| 441 | } | |
| 442 | ||
| 443 | private static StyleSpans<Collection<String>> merge( | |
| 444 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 445 | spans = spans.overlay( | |
| 446 | singleton( singletonList( style ), len ), | |
| 447 | ( bottomSpan, list ) -> { | |
| 448 | final List<String> l = | |
| 449 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 450 | l.addAll( bottomSpan ); | |
| 451 | l.addAll( list ); | |
| 452 | return l; | |
| 453 | } ); | |
| 454 | ||
| 455 | return spans; | |
| 456 | } | |
| 457 | ||
| 458 | @Override | |
| 459 | public void unstylize( final String style ) { | |
| 460 | final var indexes = mStyles.remove( style ); | |
| 461 | if( indexes != null ) { | |
| 462 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 463 | } | |
| 464 | } | |
| 465 | ||
| 466 | @Override | |
| 467 | public Caret getCaret() { | |
| 468 | return mCaret; | |
| 469 | } | |
| 470 | ||
| 471 | /** | |
| 472 | * A {@link Caret} instance is not directly coupled ot the GUI because | |
| 473 | * document processing does not always require interactive status bar | |
| 474 | * updates. This can happen when processing from the command-line. However, | |
| 475 | * the processors need the {@link Caret} instance to inject the caret | |
| 476 | * position into the document. Making the {@link CaretExtension} optional | |
| 477 | * would require more effort than using a {@link Caret} model that is | |
| 478 | * decoupled from GUI widgets. | |
| 479 | * | |
| 480 | * @param editor The text editor containing caret position information. | |
| 481 | * @return An instance of {@link Caret} that tracks the GUI caret position. | |
| 482 | */ | |
| 483 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 484 | return Caret | |
| 485 | .builder() | |
| 486 | .with( Caret.Mutator::setParagraph, | |
| 487 | () -> editor.currentParagraphProperty().getValue() ) | |
| 488 | .with( Caret.Mutator::setParagraphs, | |
| 489 | () -> editor.getParagraphs().size() ) | |
| 490 | .with( Caret.Mutator::setParaOffset, | |
| 491 | () -> editor.caretColumnProperty().getValue() ) | |
| 492 | .with( Caret.Mutator::setTextOffset, | |
| 493 | () -> editor.caretPositionProperty().getValue() ) | |
| 494 | .with( Caret.Mutator::setTextLength, | |
| 495 | () -> editor.lengthProperty().getValue() ) | |
| 496 | .build(); | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * This method adds listeners to editor events. | |
| 501 | * | |
| 502 | * @param <T> The event type. | |
| 503 | * @param <U> The consumer type for the given event type. | |
| 504 | * @param event The event of interest. | |
| 505 | * @param consumer The method to call when the event happens. | |
| 506 | */ | |
| 507 | public <T extends Event, U extends T> void addEventListener( | |
| 508 | final EventPattern<? super T, ? extends U> event, | |
| 509 | final Consumer<? super U> consumer ) { | |
| 510 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 511 | } | |
| 512 | ||
| 513 | private void onEnterPressed( final KeyEvent ignored ) { | |
| 514 | final var currentLine = getCaretParagraph(); | |
| 515 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 516 | ||
| 517 | // By default, insert a new line by itself. | |
| 518 | String newText = NEWLINE; | |
| 519 | ||
| 520 | // If the pattern was matched then determine what block type to continue. | |
| 521 | if( matcher.matches() ) { | |
| 522 | if( matcher.group( 2 ).isEmpty() ) { | |
| 523 | final var pos = mTextArea.getCaretPosition(); | |
| 524 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 525 | } | |
| 526 | else { | |
| 527 | // Indent the new line with the same whitespace characters and | |
| 528 | // list markers as current line. This ensures that the indentation | |
| 529 | // is propagated. | |
| 530 | newText = newText.concat( matcher.group( 1 ) ); | |
| 531 | } | |
| 532 | } | |
| 533 | ||
| 534 | mTextArea.replaceSelection( newText ); | |
| 535 | mTextArea.requestFollowCaret(); | |
| 536 | } | |
| 537 | ||
| 538 | private void cut( final KeyEvent event ) { | |
| 539 | cut(); | |
| 540 | } | |
| 541 | ||
| 542 | private void tab( final KeyEvent event ) { | |
| 543 | final var range = mTextArea.selectionProperty().getValue(); | |
| 544 | final var sb = new StringBuilder( 1024 ); | |
| 545 | ||
| 546 | if( range.getLength() > 0 ) { | |
| 547 | final var selection = mTextArea.getSelectedText(); | |
| 548 | ||
| 549 | selection.lines().forEach( | |
| 550 | l -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 551 | ); | |
| 552 | } | |
| 553 | else { | |
| 554 | sb.append( "\t" ); | |
| 555 | } | |
| 556 | ||
| 557 | mTextArea.replaceSelection( sb.toString() ); | |
| 558 | } | |
| 559 | ||
| 560 | private void untab( final KeyEvent event ) { | |
| 561 | final var range = mTextArea.selectionProperty().getValue(); | |
| 562 | ||
| 563 | if( range.getLength() > 0 ) { | |
| 564 | final var selection = mTextArea.getSelectedText(); | |
| 565 | final var sb = new StringBuilder( selection.length() ); | |
| 566 | ||
| 567 | selection.lines().forEach( | |
| 568 | l -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 569 | .append( NEWLINE ) | |
| 570 | ); | |
| 571 | ||
| 572 | mTextArea.replaceSelection( sb.toString() ); | |
| 573 | } | |
| 574 | else { | |
| 575 | final var p = getCaretParagraph(); | |
| 576 | ||
| 577 | if( p.startsWith( "\t" ) ) { | |
| 578 | mTextArea.selectParagraph(); | |
| 579 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 580 | } | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | /** | |
| 585 | * Observers may listen for changes to the property returned from this method | |
| 586 | * to receive notifications when either the text or caret have changed. This | |
| 587 | * should not be used to track whether the text has been modified. | |
| 588 | */ | |
| 589 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 590 | mDirty.addListener( listener ); | |
| 591 | } | |
| 592 | ||
| 593 | /** | |
| 594 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 595 | * | |
| 596 | * @param token The beginning and ending token for enclosing the text. | |
| 597 | */ | |
| 598 | private void enwrap( final String token ) { | |
| 599 | enwrap( token, token ); | |
| 600 | } | |
| 601 | ||
| 602 | /** | |
| 603 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 604 | * | |
| 605 | * @param began The beginning token for enclosing the text. | |
| 606 | * @param ended The ending token for enclosing the text. | |
| 607 | */ | |
| 608 | private void enwrap( final String began, String ended ) { | |
| 609 | // Ensure selected text takes precedence over the word at caret position. | |
| 610 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 611 | final var range = selected.getLength() == 0 | |
| 612 | ? getCaretWord() | |
| 613 | : selected; | |
| 614 | String text = mTextArea.getText( range ); | |
| 615 | ||
| 616 | int length = range.getLength(); | |
| 617 | text = trimStart( text ); | |
| 618 | final int beganIndex = range.getStart() + length - text.length(); | |
| 619 | ||
| 620 | length = text.length(); | |
| 621 | text = trimEnd( text ); | |
| 622 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 623 | ||
| 624 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 625 | } | |
| 626 | ||
| 627 | /** | |
| 628 | * Inserts the given block-level markup at the current caret position | |
| 629 | * within the document. This will prepend two blank lines to ensure that | |
| 630 | * the block element begins at the start of a new line. | |
| 631 | * | |
| 632 | * @param markup The text to insert at the caret. | |
| 633 | */ | |
| 634 | private void block( final String markup ) { | |
| 635 | final int pos = mTextArea.getCaretPosition(); | |
| 636 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 637 | } | |
| 638 | ||
| 639 | /** | |
| 640 | * Returns the caret position within the current paragraph. | |
| 641 | * | |
| 642 | * @return A value from 0 to the length of the current paragraph. | |
| 643 | */ | |
| 644 | private int getCaretColumn() { | |
| 645 | return mTextArea.getCaretColumn(); | |
| 646 | } | |
| 647 | ||
| 648 | @Override | |
| 649 | public IndexRange getCaretWord() { | |
| 650 | final var paragraph = getCaretParagraph() | |
| 651 | .replaceAll( "---", " " ) | |
| 652 | .replaceAll( "--", " " ) | |
| 653 | .replaceAll( "[\\[\\]{}()]", " " ); | |
| 654 | final var length = paragraph.length(); | |
| 655 | final var column = getCaretColumn(); | |
| 656 | ||
| 657 | var began = column; | |
| 658 | var ended = column; | |
| 659 | ||
| 660 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 661 | began--; | |
| 662 | } | |
| 663 | ||
| 664 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 665 | ended++; | |
| 666 | } | |
| 667 | ||
| 668 | final var iterator = BreakIterator.getWordInstance(); | |
| 669 | iterator.setText( paragraph ); | |
| 670 | ||
| 671 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 672 | began++; | |
| 673 | } | |
| 674 | ||
| 675 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 676 | ended--; | |
| 677 | } | |
| 678 | ||
| 679 | final var offset = getCaretDocumentOffset( column ); | |
| 680 | ||
| 681 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 682 | } | |
| 683 | ||
| 684 | private int getCaretDocumentOffset( final int column ) { | |
| 685 | return mTextArea.getCaretPosition() - column; | |
| 686 | } | |
| 687 | ||
| 688 | /** | |
| 689 | * Returns the index of the paragraph where the caret resides. | |
| 690 | * | |
| 691 | * @return A number greater than or equal to 0. | |
| 692 | */ | |
| 693 | private int getCurrentParagraph() { | |
| 694 | return mTextArea.getCurrentParagraph(); | |
| 695 | } | |
| 696 | ||
| 697 | /** | |
| 698 | * Returns the text for the paragraph that contains the caret. | |
| 699 | * | |
| 700 | * @return A non-null string, possibly empty. | |
| 701 | */ | |
| 702 | private String getCaretParagraph() { | |
| 703 | return getText( getCurrentParagraph() ); | |
| 704 | } | |
| 705 | ||
| 706 | @Override | |
| 707 | public String getText( final int paragraph ) { | |
| 708 | return mTextArea.getText( paragraph ); | |
| 709 | } | |
| 710 | ||
| 711 | @Override | |
| 712 | public String getText( final IndexRange indexes ) | |
| 713 | throws IndexOutOfBoundsException { | |
| 714 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 715 | } | |
| 716 | ||
| 717 | @Override | |
| 718 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 719 | mTextArea.replaceText( indexes, s ); | |
| 720 | } | |
| 721 | ||
| 722 | private UndoManager<?> getUndoManager() { | |
| 723 | return mTextArea.getUndoManager(); | |
| 724 | } | |
| 725 | ||
| 726 | /** | |
| 727 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 728 | * | |
| 729 | * @return A non-null string to inject into the HTML document head. | |
| 730 | */ | |
| 731 | private static String getStylesheetPath( final Locale locale ) { | |
| 732 | return MessageFormat.format( | |
| 733 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | |
| 734 | locale.getLanguage(), | |
| 735 | locale.getScript(), | |
| 736 | locale.getCountry() | |
| 737 | ); | |
| 738 | } | |
| 739 | ||
| 740 | private Locale getLocale() { | |
| 741 | return localeProperty().toLocale(); | |
| 742 | } | |
| 743 | ||
| 744 | private LocaleProperty localeProperty() { | |
| 745 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 746 | } | |
| 747 | ||
| 748 | /** | |
| 749 | * Sets the font family name and font size at the same time. When the | |
| 750 | * workspace is loaded, the default font values are changed, which results | |
| 751 | * in this method being called. | |
| 752 | * | |
| 753 | * @param area Change the font settings for this text area. | |
| 754 | * @param name New font family name to apply. | |
| 755 | * @param points New font size to apply (in points, not pixels). | |
| 756 | */ | |
| 757 | private void setFont( | |
| 758 | final StyleClassedTextArea area, final String name, final double points ) { | |
| 759 | runLater( () -> area.setStyle( | |
| 760 | format( | |
| 761 | "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points ) | |
| 762 | ) | |
| 763 | ) ); | |
| 764 | } | |
| 765 | ||
| 766 | private String getFontName() { | |
| 767 | return fontNameProperty().get(); | |
| 768 | } | |
| 769 | ||
| 770 | private StringProperty fontNameProperty() { | |
| 771 | return mWorkspace.stringProperty( KEY_UI_FONT_EDITOR_NAME ); | |
| 772 | } | |
| 773 | ||
| 774 | private double getFontSize() { | |
| 775 | return fontSizeProperty().get(); | |
| 776 | } | |
| 777 | ||
| 778 | private DoubleProperty fontSizeProperty() { | |
| 779 | return mWorkspace.doubleProperty( KEY_UI_FONT_EDITOR_SIZE ); | |
| 780 | } | |
| 781 | ||
| 782 | /** | |
| 783 | * Answers whether the given resource is of compatible {@link MediaType}s. | |
| 784 | * | |
| 785 | * @param mediaType The {@link MediaType} to compare. | |
| 786 | * @return {@code true} if the given {@link MediaType} is suitable for | |
| 787 | * editing with this type of editor. | |
| 788 | */ | |
| 789 | @Override | |
| 790 | public boolean supports( final MediaType mediaType ) { | |
| 791 | return isMediaType( mediaType ) || | |
| 792 | mediaType == TEXT_MARKDOWN || | |
| 793 | mediaType == TEXT_R_MARKDOWN; | |
| 788 | 794 | } |
| 789 | 795 | } |
| 10 | 10 | |
| 11 | 11 | import static com.keenwrite.io.MediaType.*; |
| 12 | import static java.lang.Math.min; | |
| 12 | 13 | import static java.lang.System.arraycopy; |
| 13 | 14 | import static java.util.Arrays.fill; |
| ... | ||
| 108 | 109 | }; |
| 109 | 110 | |
| 110 | for( int i = 0; i < Math.min( data.length, source.length ); i++ ) { | |
| 111 | final int length = min( data.length, source.length ); | |
| 112 | ||
| 113 | for( int i = 0; i < length; i++ ) { | |
| 111 | 114 | source[ i ] = data[ i ] & 0xFF; |
| 112 | 115 | } |
| 19 | 19 | import static com.keenwrite.io.WindowsRegistry.pathsWindows; |
| 20 | 20 | import static com.keenwrite.util.DataTypeConverter.toHex; |
| 21 | import static com.keenwrite.util.SystemUtils.IS_OS_WINDOWS; | |
| 21 | 22 | import static java.lang.System.getenv; |
| 22 | 23 | import static java.nio.file.Files.isExecutable; |
| 23 | 24 | import static java.util.regex.Pattern.quote; |
| 24 | import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS; | |
| 25 | 25 | |
| 26 | 26 | /** |
| ... | ||
| 33 | 33 | */ |
| 34 | 34 | private static final String[] EXTENSIONS = new String[] |
| 35 | {"", ".exe", ".bat", ".cmd", ".msi", ".com"}; | |
| 35 | { "", ".exe", ".bat", ".cmd", ".msi", ".com" }; | |
| 36 | 36 | |
| 37 | 37 | private static final String WHERE_COMMAND = |
| ... | ||
| 165 | 165 | public Optional<Path> where() throws IOException { |
| 166 | 166 | // The "where" command on Windows will automatically add the extension. |
| 167 | final var args = new String[]{WHERE_COMMAND, getName()}; | |
| 168 | final var output = run( text -> true, args ); | |
| 167 | final var args = new String[]{ WHERE_COMMAND, getName() }; | |
| 168 | final var output = run( _ -> true, args ); | |
| 169 | 169 | final var result = output.lines().findFirst(); |
| 170 | 170 | |
| ... | ||
| 235 | 235 | */ |
| 236 | 236 | @NotNull |
| 237 | public static String run( final Predicate<String> filter, | |
| 238 | final String[] args ) throws IOException { | |
| 237 | public static String run( | |
| 238 | final Predicate<String> filter, | |
| 239 | final String[] args ) throws IOException { | |
| 239 | 240 | final var process = Runtime.getRuntime().exec( args ); |
| 240 | 241 | final var stream = process.getInputStream(); |
| 8 | 8 | |
| 9 | 9 | import static com.keenwrite.io.SysFile.toFile; |
| 10 | import static com.keenwrite.util.SystemUtils.*; | |
| 10 | 11 | import static java.lang.System.getProperty; |
| 11 | 12 | import static java.lang.System.getenv; |
| 12 | import static org.apache.commons.lang3.SystemUtils.*; | |
| 13 | 13 | |
| 14 | 14 | /** |
| 7 | 7 | import com.keenwrite.io.MediaType; |
| 8 | 8 | import com.keenwrite.io.MediaTypeSniffer; |
| 9 | ||
| 10 | import java.io.*; | |
| 11 | import java.net.HttpURLConnection; | |
| 12 | import java.net.URI; | |
| 13 | import java.net.URISyntaxException; | |
| 14 | import java.net.URL; | |
| 15 | import java.time.Duration; | |
| 16 | import java.util.zip.GZIPInputStream; | |
| 17 | ||
| 18 | import static java.lang.Math.toIntExact; | |
| 19 | import static java.lang.String.format; | |
| 20 | import static java.lang.System.getProperty; | |
| 21 | import static java.lang.System.setProperty; | |
| 22 | import static java.net.HttpURLConnection.HTTP_OK; | |
| 23 | import static java.net.HttpURLConnection.setFollowRedirects; | |
| 24 | ||
| 25 | /** | |
| 26 | * Responsible for downloading files and publishing status updates. This will | |
| 27 | * download a resource provided by an instance of {@link URL} into a given | |
| 28 | * {@link OutputStream}. | |
| 29 | */ | |
| 30 | public final class DownloadManager { | |
| 31 | static { | |
| 32 | setProperty( "http.keepAlive", "false" ); | |
| 33 | setFollowRedirects( true ); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Number of bytes to read at a time. | |
| 38 | */ | |
| 39 | private static final int BUFFER_SIZE = 16384; | |
| 40 | ||
| 41 | /** | |
| 42 | * HTTP request timeout. | |
| 43 | */ | |
| 44 | private static final Duration TIMEOUT = Duration.ofSeconds( 30 ); | |
| 45 | ||
| 46 | @FunctionalInterface | |
| 47 | public interface ProgressListener { | |
| 48 | /** | |
| 49 | * Called when a chunk of data has been read. This is called synchronously | |
| 50 | * when downloading the data; do not execute long-running tasks in this | |
| 51 | * method (a few milliseconds is fine). | |
| 52 | * | |
| 53 | * @param percentage A value between 0 and 100, inclusive, represents the | |
| 54 | * percentage of bytes downloaded relative to the total. | |
| 55 | * A value of -1 means the total number of bytes to | |
| 56 | * download is unknown. | |
| 57 | * @param bytes When {@code percentage} is greater than or equal to | |
| 58 | * zero, this is the total number of bytes. When {@code | |
| 59 | * percentage} equals -1, this is the number of bytes | |
| 60 | * read so far. | |
| 61 | */ | |
| 62 | void update( int percentage, long bytes ); | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Callers may check the value of isSuccessful | |
| 67 | */ | |
| 68 | public static final class DownloadToken implements Closeable { | |
| 69 | private final HttpURLConnection mConn; | |
| 70 | private final BufferedInputStream mInput; | |
| 71 | private final MediaType mMediaType; | |
| 72 | private final long mBytesTotal; | |
| 73 | ||
| 74 | private DownloadToken( | |
| 75 | final HttpURLConnection conn, | |
| 76 | final BufferedInputStream input, | |
| 77 | final MediaType mediaType | |
| 78 | ) { | |
| 79 | assert conn != null; | |
| 80 | assert input != null; | |
| 81 | assert mediaType != null; | |
| 82 | ||
| 83 | mConn = conn; | |
| 84 | mInput = input; | |
| 85 | mMediaType = mediaType; | |
| 86 | mBytesTotal = conn.getContentLength(); | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Provides the ability to download remote files asynchronously while | |
| 91 | * being updated regarding the download progress. The given | |
| 92 | * {@link OutputStream} will be closed after downloading is complete. | |
| 93 | * | |
| 94 | * @param file Where to write the file contents. | |
| 95 | * @param listener Receives download progress status updates. | |
| 96 | * @return A {@link Runnable} task that can be executed in the background | |
| 97 | * to download the resource for this {@link DownloadToken}. | |
| 98 | */ | |
| 99 | public Runnable download( | |
| 100 | final File file, | |
| 101 | final ProgressListener listener ) { | |
| 102 | return () -> { | |
| 103 | final var buffer = new byte[ BUFFER_SIZE ]; | |
| 104 | final var stream = getInputStream(); | |
| 105 | final var bytesTotal = mBytesTotal; | |
| 106 | ||
| 107 | long bytesTally = 0; | |
| 108 | int bytesRead; | |
| 109 | ||
| 110 | try( final var output = new FileOutputStream( file ) ) { | |
| 111 | while( (bytesRead = stream.read( buffer )) != -1 ) { | |
| 112 | if( Thread.currentThread().isInterrupted() ) { | |
| 113 | throw new InterruptedException(); | |
| 114 | } | |
| 115 | ||
| 116 | bytesTally += bytesRead; | |
| 117 | ||
| 118 | if( bytesTotal > 0 ) { | |
| 119 | listener.update( | |
| 120 | toIntExact( bytesTally * 100 / bytesTotal ), | |
| 121 | bytesTotal | |
| 122 | ); | |
| 123 | } | |
| 124 | else { | |
| 125 | listener.update( -1, bytesRead ); | |
| 126 | } | |
| 127 | ||
| 128 | output.write( buffer, 0, bytesRead ); | |
| 129 | } | |
| 130 | } catch( final Exception ex ) { | |
| 131 | throw new RuntimeException( ex ); | |
| 132 | } finally { | |
| 133 | close(); | |
| 134 | } | |
| 135 | }; | |
| 136 | } | |
| 137 | ||
| 138 | public void close() { | |
| 139 | try { | |
| 140 | getInputStream().close(); | |
| 141 | } catch( final Exception ignored ) { | |
| 142 | } finally { | |
| 143 | mConn.disconnect(); | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * Returns the input stream to the resource to download. | |
| 149 | * | |
| 150 | * @return The stream to read. | |
| 151 | */ | |
| 152 | public BufferedInputStream getInputStream() { | |
| 153 | return mInput; | |
| 154 | } | |
| 155 | ||
| 156 | public MediaType getMediaType() { | |
| 157 | return mMediaType; | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * Answers whether the type of content associated with the download stream | |
| 162 | * is a scalable vector graphic. | |
| 163 | * | |
| 164 | * @return {@code true} if the given {@link MediaType} has SVG contents. | |
| 165 | */ | |
| 166 | public boolean isSvg() { | |
| 167 | return getMediaType().isSvg(); | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Opens the input stream for the resource to download. | |
| 173 | * | |
| 174 | * @param uri The {@link URI} resource to download. | |
| 175 | * @return A token that can be used for downloading the content with | |
| 176 | * periodic updates or retrieving the stream for downloading the content. | |
| 177 | * @throws IOException The stream could not be opened. | |
| 178 | * @throws URISyntaxException Invalid URI. | |
| 179 | */ | |
| 180 | public static DownloadToken open( final String uri ) | |
| 181 | throws IOException, URISyntaxException { | |
| 182 | // Pass an undefined media type so that any type of file can be retrieved. | |
| 183 | return open( new URI( uri ) ); | |
| 184 | } | |
| 185 | ||
| 186 | public static DownloadToken open( final URI uri ) | |
| 187 | throws IOException { | |
| 188 | return open( uri.toURL() ); | |
| 189 | } | |
| 190 | ||
| 191 | /** | |
| 192 | * Opens the input stream for the resource to download and verifies that | |
| 193 | * the given {@link MediaType} matches the requested type. Callers are | |
| 194 | * responsible for closing the {@link DownloadManager} to close the | |
| 195 | * underlying stream and the HTTP connection. Connections must be closed by | |
| 196 | * callers if {@link DownloadToken#download(File, ProgressListener)} | |
| 197 | * isn't called (i.e., {@link DownloadToken#getMediaType()} is called | |
| 198 | * after the transport layer's Content-Type is requested but not contents | |
| 199 | * are downloaded). | |
| 200 | * | |
| 201 | * @param url The {@link URL} resource to download. | |
| 202 | * @return A token that can be used for downloading the content with | |
| 203 | * periodic updates or retrieving the stream for downloading the content. | |
| 204 | * @throws IOException The resource could not be downloaded. | |
| 205 | */ | |
| 206 | public static DownloadToken open( final URL url ) throws IOException { | |
| 207 | final var conn = connect( url ); | |
| 208 | final var contentType = conn.getContentType(); | |
| 209 | ||
| 210 | MediaType remoteType; | |
| 211 | ||
| 212 | try { | |
| 213 | remoteType = MediaType.valueFrom( contentType ); | |
| 214 | } catch( final Exception ex ) { | |
| 215 | // If the media type couldn't be detected, try using the stream. | |
| 216 | remoteType = MediaType.UNDEFINED; | |
| 217 | } | |
| 218 | ||
| 219 | final var input = open( conn ); | |
| 220 | ||
| 221 | // Peek at the magic header bytes to determine the media type. | |
| 222 | final var magicType = MediaTypeSniffer.getMediaType( input ); | |
| 223 | ||
| 224 | // If the transport protocol's Content-Type doesn't align with the | |
| 225 | // media type for the magic header, defer to the transport protocol (so | |
| 226 | // long as the content type was sent from the remote side). | |
| 227 | final MediaType mediaType = remoteType.equals( magicType ) | |
| 228 | ? remoteType | |
| 229 | : contentType != null && !contentType.isBlank() | |
| 230 | ? remoteType | |
| 231 | : magicType.isUndefined() | |
| 232 | ? remoteType | |
| 233 | : magicType; | |
| 234 | ||
| 235 | return new DownloadToken( conn, input, mediaType ); | |
| 236 | } | |
| 237 | ||
| 238 | /** | |
| 239 | * Establishes a connection to the remote {@link URL} resource. | |
| 240 | * | |
| 241 | * @param url The {@link URL} representing a resource to download. | |
| 242 | * @return The connection manager for the {@link URL}. | |
| 243 | * @throws IOException Could not establish a connection. | |
| 244 | * @throws ArithmeticException Could not compute a timeout value (this | |
| 245 | * should never happen because the timeout is | |
| 246 | * less than a minute). | |
| 247 | * @see #TIMEOUT | |
| 248 | */ | |
| 249 | private static HttpURLConnection connect( final URL url ) | |
| 250 | throws IOException, ArithmeticException { | |
| 251 | // Both HTTP and HTTPS are covered by this condition. | |
| 252 | if( url.openConnection() instanceof HttpURLConnection conn ) { | |
| 253 | conn.setUseCaches( false ); | |
| 254 | conn.setInstanceFollowRedirects( true ); | |
| 255 | conn.setRequestProperty( "Accept-Encoding", "gzip" ); | |
| 256 | conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) ); | |
| 257 | conn.setRequestMethod( "GET" ); | |
| 258 | conn.setConnectTimeout( toIntExact( TIMEOUT.toMillis() ) ); | |
| 259 | conn.setRequestProperty( "connection", "close" ); | |
| 260 | conn.connect(); | |
| 261 | ||
| 262 | final var code = conn.getResponseCode(); | |
| 263 | ||
| 264 | if( code != HTTP_OK ) { | |
| 265 | final var message = format( | |
| 266 | "%s [HTTP %d: %s]", | |
| 267 | url.getFile(), | |
| 268 | code, | |
| 269 | conn.getResponseMessage() | |
| 270 | ); | |
| 271 | ||
| 272 | throw new IOException( message ); | |
| 273 | } | |
| 274 | ||
| 275 | return conn; | |
| 276 | } | |
| 277 | ||
| 278 | throw new UnsupportedOperationException( url.toString() ); | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Returns a stream in an open state. Callers are responsible for closing. | |
| 283 | * | |
| 284 | * @param conn The connection to open, which could be compressed. | |
| 285 | * @return The open stream. | |
| 286 | * @throws IOException Could not open the stream. | |
| 287 | */ | |
| 288 | private static BufferedInputStream open( final HttpURLConnection conn ) | |
| 289 | throws IOException { | |
| 290 | return open( conn.getContentEncoding(), conn.getInputStream() ); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * Returns a stream in an open state. Callers are responsible for closing. | |
| 295 | * The input stream may be compressed. | |
| 296 | * | |
| 297 | * @param encoding The content encoding for the stream. | |
| 298 | * @param is The stream to wrap with a suitable decoder. | |
| 299 | * @return The open stream, with any gzip content-encoding decoded. | |
| 300 | * @throws IOException Could not open the stream. | |
| 301 | */ | |
| 302 | private static BufferedInputStream open( | |
| 303 | final String encoding, final InputStream is ) throws IOException { | |
| 304 | return new BufferedInputStream( | |
| 305 | "gzip".equalsIgnoreCase( encoding ) | |
| 306 | ? new GZIPInputStream( is ) | |
| 307 | : is | |
| 308 | ); | |
| 9 | import com.keenwrite.io.SysFile; | |
| 10 | import javafx.concurrent.Task; | |
| 11 | ||
| 12 | import java.io.*; | |
| 13 | import java.net.HttpURLConnection; | |
| 14 | import java.net.URI; | |
| 15 | import java.net.URISyntaxException; | |
| 16 | import java.net.URL; | |
| 17 | import java.nio.file.Paths; | |
| 18 | import java.time.Duration; | |
| 19 | import java.util.concurrent.Callable; | |
| 20 | import java.util.zip.GZIPInputStream; | |
| 21 | ||
| 22 | import static java.lang.Math.toIntExact; | |
| 23 | import static java.lang.String.format; | |
| 24 | import static java.lang.System.getProperty; | |
| 25 | import static java.lang.System.setProperty; | |
| 26 | import static java.net.HttpURLConnection.HTTP_OK; | |
| 27 | import static java.net.HttpURLConnection.setFollowRedirects; | |
| 28 | ||
| 29 | /** | |
| 30 | * Responsible for downloading files and publishing status updates. This will | |
| 31 | * download a resource provided by an instance of {@link URL} into a given | |
| 32 | * {@link OutputStream}. | |
| 33 | */ | |
| 34 | public final class DownloadManager { | |
| 35 | static { | |
| 36 | setProperty( "http.keepAlive", "false" ); | |
| 37 | setFollowRedirects( true ); | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Number of bytes to read at a time. | |
| 42 | */ | |
| 43 | private static final int BUFFER_SIZE = 16384; | |
| 44 | ||
| 45 | /** | |
| 46 | * HTTP request timeout. | |
| 47 | */ | |
| 48 | private static final Duration TIMEOUT = Duration.ofSeconds( 30 ); | |
| 49 | ||
| 50 | /** | |
| 51 | * Use any of the static methods for opening by URI, URL, or string. | |
| 52 | */ | |
| 53 | private DownloadManager() {} | |
| 54 | ||
| 55 | @FunctionalInterface | |
| 56 | public interface ProgressListener { | |
| 57 | /** | |
| 58 | * Called when a chunk of data has been read. This is called synchronously | |
| 59 | * when downloading the data; do not execute long-running tasks in this | |
| 60 | * method (a few milliseconds is fine). | |
| 61 | * | |
| 62 | * @param percentage A value between 0 and 100, inclusive, represents the | |
| 63 | * percentage of bytes downloaded relative to the total. | |
| 64 | * A value of -1 means the total number of bytes to | |
| 65 | * download is unknown. | |
| 66 | * @param bytes When {@code percentage} is greater than or equal to | |
| 67 | * zero, this is the total number of bytes. When {@code | |
| 68 | * percentage} equals -1, this is the number of bytes | |
| 69 | * read so far. | |
| 70 | */ | |
| 71 | void update( int percentage, long bytes ); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Callers may check the value of isSuccessful | |
| 76 | */ | |
| 77 | public static final class DownloadToken implements Closeable { | |
| 78 | private final HttpURLConnection mConn; | |
| 79 | private final BufferedInputStream mInput; | |
| 80 | private final MediaType mMediaType; | |
| 81 | private final long mBytesTotal; | |
| 82 | ||
| 83 | private DownloadToken( | |
| 84 | final HttpURLConnection conn, | |
| 85 | final BufferedInputStream input, | |
| 86 | final MediaType mediaType | |
| 87 | ) { | |
| 88 | assert conn != null; | |
| 89 | assert input != null; | |
| 90 | assert mediaType != null; | |
| 91 | ||
| 92 | mConn = conn; | |
| 93 | mInput = input; | |
| 94 | mMediaType = mediaType; | |
| 95 | mBytesTotal = conn.getContentLength(); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Provides the ability to download remote files asynchronously while | |
| 100 | * being updated regarding the download progress. The given {@link File} | |
| 101 | * will have the contents of the URL to download upon completion. | |
| 102 | * | |
| 103 | * @param file Where to write the file contents. | |
| 104 | * @param listener Receives download progress status updates. | |
| 105 | * @return A {@link Runnable} task that can be executed in the background | |
| 106 | * to download the resource for this {@link DownloadToken}. | |
| 107 | */ | |
| 108 | public Runnable download( | |
| 109 | final File file, | |
| 110 | final ProgressListener listener ) { | |
| 111 | return () -> { | |
| 112 | final var buffer = new byte[ BUFFER_SIZE ]; | |
| 113 | final var stream = getInputStream(); | |
| 114 | final var bytesTotal = mBytesTotal; | |
| 115 | ||
| 116 | long bytesTally = 0; | |
| 117 | int bytesRead; | |
| 118 | ||
| 119 | try( final var output = new FileOutputStream( file ) ) { | |
| 120 | while( (bytesRead = stream.read( buffer )) != -1 ) { | |
| 121 | if( Thread.currentThread().isInterrupted() ) { | |
| 122 | throw new InterruptedException(); | |
| 123 | } | |
| 124 | ||
| 125 | bytesTally += bytesRead; | |
| 126 | ||
| 127 | if( bytesTotal > 0 ) { | |
| 128 | listener.update( | |
| 129 | toIntExact( bytesTally * 100 / bytesTotal ), | |
| 130 | bytesTotal | |
| 131 | ); | |
| 132 | } | |
| 133 | else { | |
| 134 | listener.update( -1, bytesRead ); | |
| 135 | } | |
| 136 | ||
| 137 | output.write( buffer, 0, bytesRead ); | |
| 138 | } | |
| 139 | } catch( final Exception ex ) { | |
| 140 | throw new RuntimeException( ex ); | |
| 141 | } finally { | |
| 142 | close(); | |
| 143 | } | |
| 144 | }; | |
| 145 | } | |
| 146 | ||
| 147 | public void close() { | |
| 148 | try { | |
| 149 | getInputStream().close(); | |
| 150 | } catch( final Exception ignored ) { | |
| 151 | } finally { | |
| 152 | mConn.disconnect(); | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Returns the input stream to the resource to download. | |
| 158 | * | |
| 159 | * @return The stream to read. | |
| 160 | */ | |
| 161 | public BufferedInputStream getInputStream() { | |
| 162 | return mInput; | |
| 163 | } | |
| 164 | ||
| 165 | public MediaType getMediaType() { | |
| 166 | return mMediaType; | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Answers whether the type of content associated with the download stream | |
| 171 | * is a scalable vector graphic. | |
| 172 | * | |
| 173 | * @return {@code true} if the given {@link MediaType} has SVG contents. | |
| 174 | */ | |
| 175 | public boolean isSvg() { | |
| 176 | return getMediaType().isSvg(); | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Opens the input stream for the resource to download. | |
| 182 | * | |
| 183 | * @param uri The {@link URI} resource to download. | |
| 184 | * @return A token that can be used for downloading the content with | |
| 185 | * periodic updates or retrieving the stream for downloading the content. | |
| 186 | * @throws IOException The stream could not be opened. | |
| 187 | * @throws URISyntaxException Invalid URI. | |
| 188 | */ | |
| 189 | public static DownloadToken open( final String uri ) | |
| 190 | throws IOException, URISyntaxException { | |
| 191 | // Pass an undefined media type so that any type of file can be retrieved. | |
| 192 | return open( new URI( uri ) ); | |
| 193 | } | |
| 194 | ||
| 195 | public static DownloadToken open( final URI uri ) | |
| 196 | throws IOException { | |
| 197 | return open( uri.toURL() ); | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * Opens the input stream for the resource to download and verifies that | |
| 202 | * the given {@link MediaType} matches the requested type. Callers are | |
| 203 | * responsible for closing the {@link DownloadManager} to close the | |
| 204 | * underlying stream and the HTTP connection. Connections must be closed by | |
| 205 | * callers if {@link DownloadToken#download(File, ProgressListener)} | |
| 206 | * isn't called (i.e., {@link DownloadToken#getMediaType()} is called | |
| 207 | * after the transport layer's Content-Type is requested but not contents | |
| 208 | * are downloaded). | |
| 209 | * | |
| 210 | * @param url The {@link URL} resource to download. | |
| 211 | * @return A token that can be used for downloading the content with | |
| 212 | * periodic updates or retrieving the stream for downloading the content. | |
| 213 | * @throws IOException The resource could not be downloaded. | |
| 214 | */ | |
| 215 | public static DownloadToken open( final URL url ) throws IOException { | |
| 216 | final var conn = connect( url ); | |
| 217 | final var contentType = conn.getContentType(); | |
| 218 | ||
| 219 | MediaType remoteType; | |
| 220 | ||
| 221 | try { | |
| 222 | remoteType = MediaType.valueFrom( contentType ); | |
| 223 | } catch( final Exception ex ) { | |
| 224 | // If the media type couldn't be detected, try using the stream. | |
| 225 | remoteType = MediaType.UNDEFINED; | |
| 226 | } | |
| 227 | ||
| 228 | final var input = open( conn ); | |
| 229 | ||
| 230 | // Peek at the magic header bytes to determine the media type. | |
| 231 | final var magicType = MediaTypeSniffer.getMediaType( input ); | |
| 232 | ||
| 233 | // If the transport protocol's Content-Type doesn't align with the | |
| 234 | // media type for the magic header, defer to the transport protocol (so | |
| 235 | // long as the content type was sent from the remote side). | |
| 236 | final MediaType mediaType = remoteType.equals( magicType ) | |
| 237 | ? remoteType | |
| 238 | : contentType != null && !contentType.isBlank() | |
| 239 | ? remoteType | |
| 240 | : magicType.isUndefined() | |
| 241 | ? remoteType | |
| 242 | : magicType; | |
| 243 | ||
| 244 | return new DownloadToken( conn, input, mediaType ); | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * Establishes a connection to the remote {@link URL} resource. | |
| 249 | * | |
| 250 | * @param url The {@link URL} representing a resource to download. | |
| 251 | * @return The connection manager for the {@link URL}. | |
| 252 | * @throws IOException Could not establish a connection. | |
| 253 | * @throws ArithmeticException Could not compute a timeout value (this | |
| 254 | * should never happen because the timeout is | |
| 255 | * less than a minute). | |
| 256 | * @see #TIMEOUT | |
| 257 | */ | |
| 258 | private static HttpURLConnection connect( final URL url ) | |
| 259 | throws IOException, ArithmeticException { | |
| 260 | // Both HTTP and HTTPS are covered by this condition. | |
| 261 | if( url.openConnection() instanceof HttpURLConnection conn ) { | |
| 262 | conn.setUseCaches( false ); | |
| 263 | conn.setInstanceFollowRedirects( true ); | |
| 264 | conn.setRequestProperty( "Accept-Encoding", "gzip" ); | |
| 265 | conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) ); | |
| 266 | conn.setRequestMethod( "GET" ); | |
| 267 | conn.setConnectTimeout( toIntExact( TIMEOUT.toMillis() ) ); | |
| 268 | conn.setRequestProperty( "connection", "close" ); | |
| 269 | conn.connect(); | |
| 270 | ||
| 271 | final var code = conn.getResponseCode(); | |
| 272 | ||
| 273 | if( code != HTTP_OK ) { | |
| 274 | final var message = format( | |
| 275 | "%s [HTTP %d: %s]", | |
| 276 | url.getFile(), | |
| 277 | code, | |
| 278 | conn.getResponseMessage() | |
| 279 | ); | |
| 280 | ||
| 281 | throw new IOException( message ); | |
| 282 | } | |
| 283 | ||
| 284 | return conn; | |
| 285 | } | |
| 286 | ||
| 287 | throw new UnsupportedOperationException( url.toString() ); | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Returns a stream in an open state. Callers are responsible for closing. | |
| 292 | * | |
| 293 | * @param conn The connection to open, which could be compressed. | |
| 294 | * @return The open stream. | |
| 295 | * @throws IOException Could not open the stream. | |
| 296 | */ | |
| 297 | private static BufferedInputStream open( final HttpURLConnection conn ) | |
| 298 | throws IOException { | |
| 299 | return open( conn.getContentEncoding(), conn.getInputStream() ); | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * Returns a stream in an open state. Callers are responsible for closing. | |
| 304 | * The input stream may be compressed. | |
| 305 | * | |
| 306 | * @param encoding The content encoding for the stream. | |
| 307 | * @param is The stream to wrap with a suitable decoder. | |
| 308 | * @return The open stream, with any gzip content-encoding decoded. | |
| 309 | * @throws IOException Could not open the stream. | |
| 310 | */ | |
| 311 | private static BufferedInputStream open( | |
| 312 | final String encoding, final InputStream is ) throws IOException { | |
| 313 | return new BufferedInputStream( | |
| 314 | "gzip".equalsIgnoreCase( encoding ) | |
| 315 | ? new GZIPInputStream( is ) | |
| 316 | : is | |
| 317 | ); | |
| 318 | } | |
| 319 | ||
| 320 | public static <T> Task<T> createTask( final Callable<T> callable ) { | |
| 321 | return new Task<>() { | |
| 322 | @Override | |
| 323 | protected T call() throws Exception { | |
| 324 | return callable.call(); | |
| 325 | } | |
| 326 | }; | |
| 327 | } | |
| 328 | ||
| 329 | public static <T> Thread createThread( final Task<T> task ) { | |
| 330 | final var thread = new Thread( task ); | |
| 331 | thread.setDaemon( true ); | |
| 332 | return thread; | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Downloads a resource to a local file in a separate {@link Thread}. | |
| 337 | * | |
| 338 | * @param uri The resource to download. | |
| 339 | * @param file The destination mTarget for the resource. | |
| 340 | * @param listener Receives updates as the download proceeds. | |
| 341 | */ | |
| 342 | public static Task<Void> downloadAsync( | |
| 343 | final URI uri, | |
| 344 | final File file, | |
| 345 | final ProgressListener listener ) { | |
| 346 | final Task<Void> task = createTask( () -> { | |
| 347 | try( final var token = DownloadManager.open( uri ) ) { | |
| 348 | token.download( file, listener ).run(); | |
| 349 | } | |
| 350 | ||
| 351 | return null; | |
| 352 | } ); | |
| 353 | ||
| 354 | createThread( task ).start(); | |
| 355 | return task; | |
| 356 | } | |
| 357 | ||
| 358 | public static String toFilename( final URI uri ) { | |
| 359 | return toFile( uri ).getName(); | |
| 360 | } | |
| 361 | ||
| 362 | public static File toFile( final URI uri ) { | |
| 363 | return SysFile.toFile( Paths.get( uri.getPath() ) ); | |
| 309 | 364 | } |
| 310 | 365 | } |
| 96 | 96 | public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" ); |
| 97 | 97 | public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" ); |
| 98 | public static final Key KEY_TYPESET_MODES = key( KEY_TYPESET, "modes" ); | |
| 99 | public static final Key KEY_TYPESET_MODES_ENABLED = key( KEY_TYPESET_MODES, "enabled" ); | |
| 98 | 100 | //@formatter:on |
| 99 | 101 |
| 62 | 62 | final var control = new SimpleFontControl( "Change" ); |
| 63 | 63 | |
| 64 | control.fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 64 | control.fontSizeProperty().addListener( ( _, _, n ) -> { | |
| 65 | 65 | if( n != null ) { |
| 66 | 66 | fontSize.set( n.doubleValue() ); |
| ... | ||
| 146 | 146 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), |
| 147 | 147 | booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) |
| 148 | ), | |
| 149 | Group.of( | |
| 150 | get( KEY_TYPESET_MODES ), | |
| 151 | Setting.of( label( KEY_TYPESET_MODES_ENABLED ) ), | |
| 152 | Setting.of( title( KEY_TYPESET_MODES_ENABLED ), | |
| 153 | stringProperty( KEY_TYPESET_MODES_ENABLED ) ) | |
| 148 | 154 | ) |
| 149 | 155 | ), |
| ... | ||
| 333 | 339 | final var view = preferences.getView(); |
| 334 | 340 | final var nodes = view.getChildrenUnmodifiable(); |
| 335 | final var master = (MasterDetailPane) nodes.get( 0 ); | |
| 341 | final var master = (MasterDetailPane) nodes.getFirst(); | |
| 336 | 342 | final var detail = (NavigationView) master.getDetailNode(); |
| 337 | 343 | final var pane = (DialogPane) view.getParent(); |
| 338 | 344 | |
| 339 | 345 | detail.setOnKeyReleased( key -> { |
| 340 | 346 | switch( key.getCode() ) { |
| 341 | 347 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); |
| 342 | 348 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); |
| 343 | default -> { } | |
| 349 | default -> {} | |
| 344 | 350 | } |
| 345 | 351 | } ); |
| ... | ||
| 353 | 359 | private void initSaveEventHandler( final PreferencesFx preferences ) { |
| 354 | 360 | preferences.addEventHandler( |
| 355 | EVENT_PREFERENCES_SAVED, event -> mWorkspace.save() | |
| 361 | EVENT_PREFERENCES_SAVED, _ -> mWorkspace.save() | |
| 356 | 362 | ); |
| 357 | 363 | } |
| ... | ||
| 368 | 374 | |
| 369 | 375 | private Node label( final Key key, final String... values ) { |
| 370 | return new Label( get( key.toString() + ".desc", (Object[]) values ) ); | |
| 376 | return new Label( get( STR."\{key.toString()}.desc", (Object[]) values ) ); | |
| 371 | 377 | } |
| 372 | 378 | |
| 373 | 379 | private String title( final Key key ) { |
| 374 | return get( key.toString() + ".title" ); | |
| 380 | return get( STR."\{key.toString()}.title" ); | |
| 375 | 381 | } |
| 376 | 382 | |
| 138 | 138 | entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ), |
| 139 | 139 | entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ), |
| 140 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ) | |
| 141 | //@formatter:on | |
| 142 | ); | |
| 143 | ||
| 144 | /** | |
| 145 | * Sets of configuration values, all the same type (e.g., file names), | |
| 146 | * where the key name doesn't change per set. | |
| 147 | */ | |
| 148 | private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | |
| 149 | entry( | |
| 150 | KEY_UI_RECENT_OPEN_PATH, | |
| 151 | createSetProperty( new HashSet<String>() ) | |
| 152 | ) | |
| 153 | ); | |
| 154 | ||
| 155 | /** | |
| 156 | * Lists of configuration values, such as key-value pairs where both the | |
| 157 | * key name and the value must be preserved per list. | |
| 158 | */ | |
| 159 | private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | |
| 160 | entry( | |
| 161 | KEY_DOC_META, | |
| 162 | createListProperty( new LinkedList<Entry<String, String>>() ) | |
| 163 | ) | |
| 164 | ); | |
| 165 | ||
| 166 | /** | |
| 167 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 168 | */ | |
| 169 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 170 | Map.of( | |
| 171 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 172 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 173 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 174 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 175 | SimpleFloatProperty.class, Float::parseFloat, | |
| 176 | SimpleStringProperty.class, String::new, | |
| 177 | SimpleObjectProperty.class, String::new, | |
| 178 | SkinProperty.class, String::new, | |
| 179 | FileProperty.class, File::new | |
| 180 | ); | |
| 181 | ||
| 182 | /** | |
| 183 | * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | |
| 184 | * can simply call {@link Object#toString()} to convert the value to a string. | |
| 185 | */ | |
| 186 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 187 | Map.of( | |
| 188 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 189 | ); | |
| 190 | ||
| 191 | /** | |
| 192 | * Converts the given {@link Property} value to a string. | |
| 193 | * | |
| 194 | * @param property The {@link Property} to convert. | |
| 195 | * @return A string representation of the given property, or the empty | |
| 196 | * string if no conversion was possible. | |
| 197 | */ | |
| 198 | private static String marshall( final Property<?> property ) { | |
| 199 | final var v = property.getValue(); | |
| 200 | ||
| 201 | return v == null | |
| 202 | ? "" | |
| 203 | : MARSHALL | |
| 204 | .getOrDefault( property.getClass(), __ -> property.getValue() ) | |
| 205 | .apply( v.toString() ) | |
| 206 | .toString(); | |
| 207 | } | |
| 208 | ||
| 209 | private static Object unmarshall( | |
| 210 | final Property<?> property, final Object configValue ) { | |
| 211 | final var v = configValue.toString(); | |
| 212 | ||
| 213 | return UNMARSHALL | |
| 214 | .getOrDefault( property.getClass(), value -> property.getValue() ) | |
| 215 | .apply( v ); | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * Creates an instance of {@link ObservableList} that is based on a | |
| 220 | * modifiable observable array list for the given items. | |
| 221 | * | |
| 222 | * @param items The items to wrap in an observable list. | |
| 223 | * @param <E> The type of items to add to the list. | |
| 224 | * @return An observable property that can have its contents modified. | |
| 225 | */ | |
| 226 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 227 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 228 | } | |
| 229 | ||
| 230 | private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | |
| 231 | return new SimpleSetProperty<>( observableSet( set ) ); | |
| 232 | } | |
| 233 | ||
| 234 | private static <E> ListProperty<E> createListProperty( final List<E> list ) { | |
| 235 | return new SimpleListProperty<>( observableArrayList( list ) ); | |
| 236 | } | |
| 237 | ||
| 238 | private static StringProperty asStringProperty( final String value ) { | |
| 239 | return new SimpleStringProperty( value ); | |
| 240 | } | |
| 241 | ||
| 242 | private static BooleanProperty asBooleanProperty() { | |
| 243 | return new SimpleBooleanProperty(); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * @param value Default value. | |
| 248 | */ | |
| 249 | @SuppressWarnings( "SameParameterValue" ) | |
| 250 | private static BooleanProperty asBooleanProperty( final boolean value ) { | |
| 251 | return new SimpleBooleanProperty( value ); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * @param value Default value. | |
| 256 | */ | |
| 257 | @SuppressWarnings( "SameParameterValue" ) | |
| 258 | private static IntegerProperty asIntegerProperty( final int value ) { | |
| 259 | return new SimpleIntegerProperty( value ); | |
| 260 | } | |
| 261 | ||
| 262 | /** | |
| 263 | * @param value Default value. | |
| 264 | */ | |
| 265 | private static DoubleProperty asDoubleProperty( final double value ) { | |
| 266 | return new SimpleDoubleProperty( value ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * @param value Default value. | |
| 271 | */ | |
| 272 | private static FileProperty asFileProperty( final File value ) { | |
| 273 | return new FileProperty( value ); | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * @param value Default value. | |
| 278 | */ | |
| 279 | @SuppressWarnings( "SameParameterValue" ) | |
| 280 | private static LocaleProperty asLocaleProperty( final Locale value ) { | |
| 281 | return new LocaleProperty( value ); | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * @param value Default value. | |
| 286 | */ | |
| 287 | @SuppressWarnings( "SameParameterValue" ) | |
| 288 | private static SkinProperty asSkinProperty( final String value ) { | |
| 289 | return new SkinProperty( value ); | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates a new {@link Workspace} that will attempt to load the users' | |
| 294 | * preferences. If the configuration file cannot be loaded, the workspace | |
| 295 | * settings returns default values. | |
| 296 | */ | |
| 297 | public Workspace() { | |
| 298 | load(); | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Attempts to load the app's configuration file. | |
| 303 | */ | |
| 304 | private void load() { | |
| 305 | final var store = createXmlStore(); | |
| 306 | store.load( FILE_PREFERENCES ); | |
| 307 | ||
| 308 | mValues.keySet().forEach( key -> { | |
| 309 | try { | |
| 310 | final var storeValue = store.getValue( key ); | |
| 311 | final var property = valuesProperty( key ); | |
| 312 | final var unmarshalled = unmarshall( property, storeValue ); | |
| 313 | ||
| 314 | property.setValue( unmarshalled ); | |
| 315 | } catch( final NoSuchElementException ex ) { | |
| 316 | // When no configuration (item), use the default value. | |
| 317 | clue( ex ); | |
| 318 | } | |
| 319 | } ); | |
| 320 | ||
| 321 | mSets.keySet().forEach( key -> { | |
| 322 | final var set = store.getSet( key ); | |
| 323 | final SetProperty<String> property = setsProperty( key ); | |
| 324 | ||
| 325 | property.setValue( observableSet( set ) ); | |
| 326 | } ); | |
| 327 | ||
| 328 | mLists.keySet().forEach( key -> { | |
| 329 | final var map = store.getMap( key ); | |
| 330 | final ListProperty<Entry<String, String>> property = listsProperty( key ); | |
| 331 | final var list = map | |
| 332 | .entrySet() | |
| 333 | .stream() | |
| 334 | .toList(); | |
| 335 | ||
| 336 | property.setValue( observableArrayList( list ) ); | |
| 337 | } ); | |
| 338 | ||
| 339 | WorkspaceLoadedEvent.fire( this ); | |
| 340 | } | |
| 341 | ||
| 342 | /** | |
| 343 | * Saves the current workspace. | |
| 344 | */ | |
| 345 | public void save() { | |
| 346 | final var store = createXmlStore(); | |
| 347 | ||
| 348 | try { | |
| 349 | // Update the string values to include the application version. | |
| 350 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 351 | ||
| 352 | mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | |
| 353 | mSets.forEach( store::setSet ); | |
| 354 | mLists.forEach( store::setMap ); | |
| 355 | ||
| 356 | store.save( FILE_PREFERENCES ); | |
| 357 | } catch( final Exception ex ) { | |
| 358 | clue( ex ); | |
| 359 | } | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Returns a value that represents a setting in the application that the user | |
| 364 | * may configure, either directly or indirectly. | |
| 365 | * | |
| 366 | * @param key The reference to the users' preference stored in deference | |
| 367 | * of app reëntrance. | |
| 368 | * @return An observable property to be persisted. | |
| 369 | */ | |
| 370 | @SuppressWarnings( "unchecked" ) | |
| 371 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 372 | assert key != null; | |
| 373 | return (U) mValues.get( key ); | |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * Returns a set of values that represent a setting in the application that | |
| 378 | * the user may configure, either directly or indirectly. The property | |
| 379 | * returned is backed by a {@link Set}. | |
| 380 | * | |
| 381 | * @param key The {@link Key} associated with a preference value. | |
| 382 | * @return An observable property to be persisted. | |
| 383 | */ | |
| 384 | @SuppressWarnings( "unchecked" ) | |
| 385 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 386 | assert key != null; | |
| 387 | return (SetProperty<T>) mSets.get( key ); | |
| 388 | } | |
| 389 | ||
| 390 | /** | |
| 391 | * Returns a list of values that represent a setting in the application that | |
| 392 | * the user may configure, either directly or indirectly. The property | |
| 393 | * returned is backed by a mutable {@link List}. | |
| 394 | * | |
| 395 | * @param key The {@link Key} associated with a preference value. | |
| 396 | * @return An observable property to be persisted. | |
| 397 | */ | |
| 398 | @SuppressWarnings( "unchecked" ) | |
| 399 | public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | |
| 400 | assert key != null; | |
| 401 | return (ListProperty<Entry<K, V>>) mLists.get( key ); | |
| 402 | } | |
| 403 | ||
| 404 | /** | |
| 405 | * Returns the {@link String} {@link Property} associated with the given | |
| 406 | * {@link Key} from the internal list of preference values. The caller | |
| 407 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 408 | * {@link Property}. | |
| 409 | * | |
| 410 | * @param key The {@link Key} associated with a preference value. | |
| 411 | * @return The value associated with the given {@link Key}. | |
| 412 | */ | |
| 413 | public StringProperty stringProperty( final Key key ) { | |
| 414 | assert key != null; | |
| 415 | return valuesProperty( key ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Returns the {@link Boolean} {@link Property} associated with the given | |
| 420 | * {@link Key} from the internal list of preference values. The caller | |
| 421 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 422 | * {@link Property}. | |
| 423 | * | |
| 424 | * @param key The {@link Key} associated with a preference value. | |
| 425 | * @return The value associated with the given {@link Key}. | |
| 426 | */ | |
| 427 | public BooleanProperty booleanProperty( final Key key ) { | |
| 428 | assert key != null; | |
| 429 | return valuesProperty( key ); | |
| 430 | } | |
| 431 | ||
| 432 | /** | |
| 433 | * Returns the {@link Integer} {@link Property} associated with the given | |
| 434 | * {@link Key} from the internal list of preference values. The caller | |
| 435 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 436 | * {@link Property}. | |
| 437 | * | |
| 438 | * @param key The {@link Key} associated with a preference value. | |
| 439 | * @return The value associated with the given {@link Key}. | |
| 440 | */ | |
| 441 | public IntegerProperty integerProperty( final Key key ) { | |
| 442 | assert key != null; | |
| 443 | return valuesProperty( key ); | |
| 444 | } | |
| 445 | ||
| 446 | /** | |
| 447 | * Returns the {@link Double} {@link Property} associated with the given | |
| 448 | * {@link Key} from the internal list of preference values. The caller | |
| 449 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 450 | * {@link Property}. | |
| 451 | * | |
| 452 | * @param key The {@link Key} associated with a preference value. | |
| 453 | * @return The value associated with the given {@link Key}. | |
| 454 | */ | |
| 455 | public DoubleProperty doubleProperty( final Key key ) { | |
| 456 | assert key != null; | |
| 457 | return valuesProperty( key ); | |
| 458 | } | |
| 459 | ||
| 460 | /** | |
| 461 | * Returns the {@link File} {@link Property} associated with the given | |
| 462 | * {@link Key} from the internal list of preference values. The caller | |
| 463 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 464 | * {@link Property}. | |
| 465 | * | |
| 466 | * @param key The {@link Key} associated with a preference value. | |
| 467 | * @return The value associated with the given {@link Key}. | |
| 468 | */ | |
| 469 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 470 | assert key != null; | |
| 471 | return valuesProperty( key ); | |
| 472 | } | |
| 473 | ||
| 474 | /** | |
| 475 | * Returns the {@link Locale} {@link Property} associated with the given | |
| 476 | * {@link Key} from the internal list of preference values. The caller | |
| 477 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 478 | * {@link Property}. | |
| 479 | * | |
| 480 | * @param key The {@link Key} associated with a preference value. | |
| 481 | * @return The value associated with the given {@link Key}. | |
| 482 | */ | |
| 483 | public LocaleProperty localeProperty( final Key key ) { | |
| 484 | assert key != null; | |
| 485 | return valuesProperty( key ); | |
| 486 | } | |
| 487 | ||
| 488 | public ObjectProperty<String> skinProperty( final Key key ) { | |
| 489 | assert key != null; | |
| 490 | return valuesProperty( key ); | |
| 491 | } | |
| 492 | ||
| 493 | public String getString( final Key key ) { | |
| 494 | assert key != null; | |
| 495 | return stringProperty( key ).get(); | |
| 496 | } | |
| 497 | ||
| 498 | /** | |
| 499 | * Returns the {@link Boolean} preference value associated with the given | |
| 500 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 501 | * associated with a value that matches the return type. | |
| 502 | * | |
| 503 | * @param key The {@link Key} associated with a preference value. | |
| 504 | * @return The value associated with the given {@link Key}. | |
| 505 | */ | |
| 506 | public boolean getBoolean( final Key key ) { | |
| 507 | assert key != null; | |
| 508 | return booleanProperty( key ).get(); | |
| 509 | } | |
| 510 | ||
| 511 | /** | |
| 512 | * Returns the {@link Integer} preference value associated with the given | |
| 513 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 514 | * associated with a value that matches the return type. | |
| 515 | * | |
| 516 | * @param key The {@link Key} associated with a preference value. | |
| 517 | * @return The value associated with the given {@link Key}. | |
| 518 | */ | |
| 519 | @SuppressWarnings( "unused" ) | |
| 520 | public int getInteger( final Key key ) { | |
| 521 | assert key != null; | |
| 522 | return integerProperty( key ).get(); | |
| 523 | } | |
| 524 | ||
| 525 | /** | |
| 526 | * Returns the {@link Double} preference value associated with the given | |
| 527 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 528 | * associated with a value that matches the return type. | |
| 529 | * | |
| 530 | * @param key The {@link Key} associated with a preference value. | |
| 531 | * @return The value associated with the given {@link Key}. | |
| 532 | */ | |
| 533 | public double getDouble( final Key key ) { | |
| 534 | assert key != null; | |
| 535 | return doubleProperty( key ).get(); | |
| 536 | } | |
| 537 | ||
| 538 | /** | |
| 539 | * Returns the {@link File} preference value associated with the given | |
| 540 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 541 | * associated with a value that matches the return type. | |
| 542 | * | |
| 543 | * @param key The {@link Key} associated with a preference value. | |
| 544 | * @return The value associated with the given {@link Key}. | |
| 545 | */ | |
| 546 | public File getFile( final Key key ) { | |
| 547 | assert key != null; | |
| 548 | return fileProperty( key ).get(); | |
| 549 | } | |
| 550 | ||
| 551 | /** | |
| 552 | * Returns the language locale setting for the | |
| 553 | * {@link AppKeys#KEY_LANGUAGE_LOCALE} key. | |
| 554 | * | |
| 555 | * @return The user's current locale setting. | |
| 556 | */ | |
| 557 | public Locale getLocale() { | |
| 558 | return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | |
| 559 | } | |
| 560 | ||
| 561 | @SuppressWarnings( "unchecked" ) | |
| 562 | public <K, V> Map<K, V> getMetadata() { | |
| 563 | final var metadata = listsProperty( KEY_DOC_META ); | |
| 564 | final HashMap<K, V> map; | |
| 565 | ||
| 566 | if( metadata != null ) { | |
| 567 | map = new HashMap<>( metadata.size() ); | |
| 568 | ||
| 569 | metadata.forEach( | |
| 570 | entry -> map.put( (K) entry.getKey(), (V) entry.getValue() ) | |
| 571 | ); | |
| 572 | } | |
| 573 | else { | |
| 574 | map = new HashMap<>(); | |
| 575 | } | |
| 576 | ||
| 577 | return map; | |
| 578 | } | |
| 579 | ||
| 580 | public Path getThemesPath() { | |
| 581 | final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 582 | final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 583 | ||
| 584 | return Path.of( dir.toString(), name ); | |
| 585 | } | |
| 586 | ||
| 587 | /** | |
| 588 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 589 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 590 | * indicate the property changes always take effect. | |
| 591 | * | |
| 592 | * @param key The value to bind to the internal key property. | |
| 593 | * @param property The external property value that sets the internal value. | |
| 594 | */ | |
| 595 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 596 | assert key != null; | |
| 597 | assert property != null; | |
| 598 | ||
| 599 | listen( key, property, () -> true ); | |
| 600 | } | |
| 601 | ||
| 602 | /** | |
| 603 | * Binds a read-only property to a value in the preferences. This allows | |
| 604 | * user interface properties to change and the preferences will be | |
| 605 | * synchronized automatically. | |
| 606 | * <p> | |
| 607 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 608 | * application window states are finished before assessing whether property | |
| 609 | * changes should be applied. Without this, exiting the application while the | |
| 610 | * window is maximized would persist the window's maximum dimensions, | |
| 611 | * preventing restoration to its prior, non-maximum size. | |
| 612 | * | |
| 613 | * @param key The value to bind to the internal key property. | |
| 614 | * @param property The external property value that sets the internal value. | |
| 615 | * @param enabled Indicates whether property changes should be applied. | |
| 616 | */ | |
| 617 | public <T> void listen( | |
| 618 | final Key key, | |
| 619 | final ReadOnlyProperty<T> property, | |
| 620 | final BooleanSupplier enabled ) { | |
| 621 | assert key != null; | |
| 622 | assert property != null; | |
| 623 | assert enabled != null; | |
| 624 | ||
| 625 | property.addListener( | |
| 626 | ( c, o, n ) -> runLater( () -> { | |
| 140 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ), | |
| 141 | entry( KEY_TYPESET_MODES_ENABLED, asStringProperty( "" ) ) | |
| 142 | //@formatter:on | |
| 143 | ); | |
| 144 | ||
| 145 | /** | |
| 146 | * Sets of configuration values, all the same type (e.g., file names), | |
| 147 | * where the key name doesn't change per set. | |
| 148 | */ | |
| 149 | private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | |
| 150 | entry( | |
| 151 | KEY_UI_RECENT_OPEN_PATH, | |
| 152 | createSetProperty( new HashSet<String>() ) | |
| 153 | ) | |
| 154 | ); | |
| 155 | ||
| 156 | /** | |
| 157 | * Lists of configuration values, such as key-value pairs where both the | |
| 158 | * key name and the value must be preserved per list. | |
| 159 | */ | |
| 160 | private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | |
| 161 | entry( | |
| 162 | KEY_DOC_META, | |
| 163 | createListProperty( new LinkedList<Entry<String, String>>() ) | |
| 164 | ) | |
| 165 | ); | |
| 166 | ||
| 167 | /** | |
| 168 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 169 | */ | |
| 170 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 171 | Map.of( | |
| 172 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 173 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 174 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 175 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 176 | SimpleFloatProperty.class, Float::parseFloat, | |
| 177 | SimpleStringProperty.class, String::new, | |
| 178 | SimpleObjectProperty.class, String::new, | |
| 179 | SkinProperty.class, String::new, | |
| 180 | FileProperty.class, File::new | |
| 181 | ); | |
| 182 | ||
| 183 | /** | |
| 184 | * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | |
| 185 | * can simply call {@link Object#toString()} to convert the value to a string. | |
| 186 | */ | |
| 187 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 188 | Map.of( | |
| 189 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 190 | ); | |
| 191 | ||
| 192 | /** | |
| 193 | * Converts the given {@link Property} value to a string. | |
| 194 | * | |
| 195 | * @param property The {@link Property} to convert. | |
| 196 | * @return A string representation of the given property, or the empty | |
| 197 | * string if no conversion was possible. | |
| 198 | */ | |
| 199 | private static String marshall( final Property<?> property ) { | |
| 200 | final var v = property.getValue(); | |
| 201 | ||
| 202 | return v == null | |
| 203 | ? "" | |
| 204 | : MARSHALL | |
| 205 | .getOrDefault( property.getClass(), _ -> property.getValue() ) | |
| 206 | .apply( v.toString() ) | |
| 207 | .toString(); | |
| 208 | } | |
| 209 | ||
| 210 | private static Object unmarshall( | |
| 211 | final Property<?> property, final Object configValue ) { | |
| 212 | final var v = configValue.toString(); | |
| 213 | ||
| 214 | return UNMARSHALL | |
| 215 | .getOrDefault( property.getClass(), _ -> property.getValue() ) | |
| 216 | .apply( v ); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Creates an instance of {@link ObservableList} that is based on a | |
| 221 | * modifiable observable array list for the given items. | |
| 222 | * | |
| 223 | * @param items The items to wrap in an observable list. | |
| 224 | * @param <E> The type of items to add to the list. | |
| 225 | * @return An observable property that can have its contents modified. | |
| 226 | */ | |
| 227 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 228 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 229 | } | |
| 230 | ||
| 231 | private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | |
| 232 | return new SimpleSetProperty<>( observableSet( set ) ); | |
| 233 | } | |
| 234 | ||
| 235 | private static <E> ListProperty<E> createListProperty( final List<E> list ) { | |
| 236 | return new SimpleListProperty<>( observableArrayList( list ) ); | |
| 237 | } | |
| 238 | ||
| 239 | private static StringProperty asStringProperty( final String value ) { | |
| 240 | return new SimpleStringProperty( value ); | |
| 241 | } | |
| 242 | ||
| 243 | private static BooleanProperty asBooleanProperty() { | |
| 244 | return new SimpleBooleanProperty(); | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * @param value Default value. | |
| 249 | */ | |
| 250 | @SuppressWarnings( "SameParameterValue" ) | |
| 251 | private static BooleanProperty asBooleanProperty( final boolean value ) { | |
| 252 | return new SimpleBooleanProperty( value ); | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * @param value Default value. | |
| 257 | */ | |
| 258 | @SuppressWarnings( "SameParameterValue" ) | |
| 259 | private static IntegerProperty asIntegerProperty( final int value ) { | |
| 260 | return new SimpleIntegerProperty( value ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * @param value Default value. | |
| 265 | */ | |
| 266 | private static DoubleProperty asDoubleProperty( final double value ) { | |
| 267 | return new SimpleDoubleProperty( value ); | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * @param value Default value. | |
| 272 | */ | |
| 273 | private static FileProperty asFileProperty( final File value ) { | |
| 274 | return new FileProperty( value ); | |
| 275 | } | |
| 276 | ||
| 277 | /** | |
| 278 | * @param value Default value. | |
| 279 | */ | |
| 280 | @SuppressWarnings( "SameParameterValue" ) | |
| 281 | private static LocaleProperty asLocaleProperty( final Locale value ) { | |
| 282 | return new LocaleProperty( value ); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * @param value Default value. | |
| 287 | */ | |
| 288 | @SuppressWarnings( "SameParameterValue" ) | |
| 289 | private static SkinProperty asSkinProperty( final String value ) { | |
| 290 | return new SkinProperty( value ); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * Creates a new {@link Workspace} that will attempt to load the users' | |
| 295 | * preferences. If the configuration file cannot be loaded, the workspace | |
| 296 | * settings returns default values. | |
| 297 | */ | |
| 298 | public Workspace() { | |
| 299 | load(); | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * Attempts to load the app's configuration file. | |
| 304 | */ | |
| 305 | private void load() { | |
| 306 | final var store = createXmlStore(); | |
| 307 | store.load( FILE_PREFERENCES ); | |
| 308 | ||
| 309 | mValues.keySet().forEach( key -> { | |
| 310 | try { | |
| 311 | final var storeValue = store.getValue( key ); | |
| 312 | final var property = valuesProperty( key ); | |
| 313 | final var unmarshalled = unmarshall( property, storeValue ); | |
| 314 | ||
| 315 | property.setValue( unmarshalled ); | |
| 316 | } catch( final NoSuchElementException ex ) { | |
| 317 | // When no configuration (item), use the default value. | |
| 318 | clue( ex ); | |
| 319 | } | |
| 320 | } ); | |
| 321 | ||
| 322 | mSets.keySet().forEach( key -> { | |
| 323 | final var set = store.getSet( key ); | |
| 324 | final SetProperty<String> property = setsProperty( key ); | |
| 325 | ||
| 326 | property.setValue( observableSet( set ) ); | |
| 327 | } ); | |
| 328 | ||
| 329 | mLists.keySet().forEach( key -> { | |
| 330 | final var map = store.getMap( key ); | |
| 331 | final ListProperty<Entry<String, String>> property = listsProperty( key ); | |
| 332 | final var list = map | |
| 333 | .entrySet() | |
| 334 | .stream() | |
| 335 | .toList(); | |
| 336 | ||
| 337 | property.setValue( observableArrayList( list ) ); | |
| 338 | } ); | |
| 339 | ||
| 340 | WorkspaceLoadedEvent.fire( this ); | |
| 341 | } | |
| 342 | ||
| 343 | /** | |
| 344 | * Saves the current workspace. | |
| 345 | */ | |
| 346 | public void save() { | |
| 347 | final var store = createXmlStore(); | |
| 348 | ||
| 349 | try { | |
| 350 | // Update the string values to include the application version. | |
| 351 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 352 | ||
| 353 | mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | |
| 354 | mSets.forEach( store::setSet ); | |
| 355 | mLists.forEach( store::setMap ); | |
| 356 | ||
| 357 | store.save( FILE_PREFERENCES ); | |
| 358 | } catch( final Exception ex ) { | |
| 359 | clue( ex ); | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | /** | |
| 364 | * Returns a value that represents a setting in the application that the user | |
| 365 | * may configure, either directly or indirectly. | |
| 366 | * | |
| 367 | * @param key The reference to the users' preference stored in deference | |
| 368 | * of app reëntrance. | |
| 369 | * @return An observable property to be persisted. | |
| 370 | */ | |
| 371 | @SuppressWarnings( "unchecked" ) | |
| 372 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 373 | assert key != null; | |
| 374 | return (U) mValues.get( key ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Returns a set of values that represent a setting in the application that | |
| 379 | * the user may configure, either directly or indirectly. The property | |
| 380 | * returned is backed by a {@link Set}. | |
| 381 | * | |
| 382 | * @param key The {@link Key} associated with a preference value. | |
| 383 | * @return An observable property to be persisted. | |
| 384 | */ | |
| 385 | @SuppressWarnings( "unchecked" ) | |
| 386 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 387 | assert key != null; | |
| 388 | return (SetProperty<T>) mSets.get( key ); | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * Returns a list of values that represent a setting in the application that | |
| 393 | * the user may configure, either directly or indirectly. The property | |
| 394 | * returned is backed by a mutable {@link List}. | |
| 395 | * | |
| 396 | * @param key The {@link Key} associated with a preference value. | |
| 397 | * @return An observable property to be persisted. | |
| 398 | */ | |
| 399 | @SuppressWarnings( "unchecked" ) | |
| 400 | public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | |
| 401 | assert key != null; | |
| 402 | return (ListProperty<Entry<K, V>>) mLists.get( key ); | |
| 403 | } | |
| 404 | ||
| 405 | /** | |
| 406 | * Returns the {@link String} {@link Property} associated with the given | |
| 407 | * {@link Key} from the internal list of preference values. The caller | |
| 408 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 409 | * {@link Property}. | |
| 410 | * | |
| 411 | * @param key The {@link Key} associated with a preference value. | |
| 412 | * @return The value associated with the given {@link Key}. | |
| 413 | */ | |
| 414 | public StringProperty stringProperty( final Key key ) { | |
| 415 | assert key != null; | |
| 416 | return valuesProperty( key ); | |
| 417 | } | |
| 418 | ||
| 419 | /** | |
| 420 | * Returns the {@link Boolean} {@link Property} associated with the given | |
| 421 | * {@link Key} from the internal list of preference values. The caller | |
| 422 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 423 | * {@link Property}. | |
| 424 | * | |
| 425 | * @param key The {@link Key} associated with a preference value. | |
| 426 | * @return The value associated with the given {@link Key}. | |
| 427 | */ | |
| 428 | public BooleanProperty booleanProperty( final Key key ) { | |
| 429 | assert key != null; | |
| 430 | return valuesProperty( key ); | |
| 431 | } | |
| 432 | ||
| 433 | /** | |
| 434 | * Returns the {@link Integer} {@link Property} associated with the given | |
| 435 | * {@link Key} from the internal list of preference values. The caller | |
| 436 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 437 | * {@link Property}. | |
| 438 | * | |
| 439 | * @param key The {@link Key} associated with a preference value. | |
| 440 | * @return The value associated with the given {@link Key}. | |
| 441 | */ | |
| 442 | public IntegerProperty integerProperty( final Key key ) { | |
| 443 | assert key != null; | |
| 444 | return valuesProperty( key ); | |
| 445 | } | |
| 446 | ||
| 447 | /** | |
| 448 | * Returns the {@link Double} {@link Property} associated with the given | |
| 449 | * {@link Key} from the internal list of preference values. The caller | |
| 450 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 451 | * {@link Property}. | |
| 452 | * | |
| 453 | * @param key The {@link Key} associated with a preference value. | |
| 454 | * @return The value associated with the given {@link Key}. | |
| 455 | */ | |
| 456 | public DoubleProperty doubleProperty( final Key key ) { | |
| 457 | assert key != null; | |
| 458 | return valuesProperty( key ); | |
| 459 | } | |
| 460 | ||
| 461 | /** | |
| 462 | * Returns the {@link File} {@link Property} associated with the given | |
| 463 | * {@link Key} from the internal list of preference values. The caller | |
| 464 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 465 | * {@link Property}. | |
| 466 | * | |
| 467 | * @param key The {@link Key} associated with a preference value. | |
| 468 | * @return The value associated with the given {@link Key}. | |
| 469 | */ | |
| 470 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 471 | assert key != null; | |
| 472 | return valuesProperty( key ); | |
| 473 | } | |
| 474 | ||
| 475 | /** | |
| 476 | * Returns the {@link Locale} {@link Property} associated with the given | |
| 477 | * {@link Key} from the internal list of preference values. The caller | |
| 478 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 479 | * {@link Property}. | |
| 480 | * | |
| 481 | * @param key The {@link Key} associated with a preference value. | |
| 482 | * @return The value associated with the given {@link Key}. | |
| 483 | */ | |
| 484 | public LocaleProperty localeProperty( final Key key ) { | |
| 485 | assert key != null; | |
| 486 | return valuesProperty( key ); | |
| 487 | } | |
| 488 | ||
| 489 | public ObjectProperty<String> skinProperty( final Key key ) { | |
| 490 | assert key != null; | |
| 491 | return valuesProperty( key ); | |
| 492 | } | |
| 493 | ||
| 494 | public String getString( final Key key ) { | |
| 495 | assert key != null; | |
| 496 | return stringProperty( key ).get(); | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * Returns the {@link Boolean} preference value associated with the given | |
| 501 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 502 | * associated with a value that matches the return type. | |
| 503 | * | |
| 504 | * @param key The {@link Key} associated with a preference value. | |
| 505 | * @return The value associated with the given {@link Key}. | |
| 506 | */ | |
| 507 | public boolean getBoolean( final Key key ) { | |
| 508 | assert key != null; | |
| 509 | return booleanProperty( key ).get(); | |
| 510 | } | |
| 511 | ||
| 512 | /** | |
| 513 | * Returns the {@link Integer} preference value associated with the given | |
| 514 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 515 | * associated with a value that matches the return type. | |
| 516 | * | |
| 517 | * @param key The {@link Key} associated with a preference value. | |
| 518 | * @return The value associated with the given {@link Key}. | |
| 519 | */ | |
| 520 | @SuppressWarnings( "unused" ) | |
| 521 | public int getInteger( final Key key ) { | |
| 522 | assert key != null; | |
| 523 | return integerProperty( key ).get(); | |
| 524 | } | |
| 525 | ||
| 526 | /** | |
| 527 | * Returns the {@link Double} preference value associated with the given | |
| 528 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 529 | * associated with a value that matches the return type. | |
| 530 | * | |
| 531 | * @param key The {@link Key} associated with a preference value. | |
| 532 | * @return The value associated with the given {@link Key}. | |
| 533 | */ | |
| 534 | public double getDouble( final Key key ) { | |
| 535 | assert key != null; | |
| 536 | return doubleProperty( key ).get(); | |
| 537 | } | |
| 538 | ||
| 539 | /** | |
| 540 | * Returns the {@link File} preference value associated with the given | |
| 541 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 542 | * associated with a value that matches the return type. | |
| 543 | * | |
| 544 | * @param key The {@link Key} associated with a preference value. | |
| 545 | * @return The value associated with the given {@link Key}. | |
| 546 | */ | |
| 547 | public File getFile( final Key key ) { | |
| 548 | assert key != null; | |
| 549 | return fileProperty( key ).get(); | |
| 550 | } | |
| 551 | ||
| 552 | /** | |
| 553 | * Returns the language locale setting for the | |
| 554 | * {@link AppKeys#KEY_LANGUAGE_LOCALE} key. | |
| 555 | * | |
| 556 | * @return The user's current locale setting. | |
| 557 | */ | |
| 558 | public Locale getLocale() { | |
| 559 | return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | |
| 560 | } | |
| 561 | ||
| 562 | @SuppressWarnings( "unchecked" ) | |
| 563 | public <K, V> Map<K, V> getMetadata() { | |
| 564 | final var metadata = listsProperty( KEY_DOC_META ); | |
| 565 | final HashMap<K, V> map; | |
| 566 | ||
| 567 | if( metadata != null ) { | |
| 568 | map = new HashMap<>( metadata.size() ); | |
| 569 | ||
| 570 | metadata.forEach( | |
| 571 | entry -> map.put( (K) entry.getKey(), (V) entry.getValue() ) | |
| 572 | ); | |
| 573 | } | |
| 574 | else { | |
| 575 | map = new HashMap<>(); | |
| 576 | } | |
| 577 | ||
| 578 | return map; | |
| 579 | } | |
| 580 | ||
| 581 | public Path getThemesPath() { | |
| 582 | final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 583 | final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 584 | ||
| 585 | return Path.of( dir.toString(), name ); | |
| 586 | } | |
| 587 | ||
| 588 | /** | |
| 589 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 590 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 591 | * indicate the property changes always take effect. | |
| 592 | * | |
| 593 | * @param key The value to bind to the internal key property. | |
| 594 | * @param property The external property value that sets the internal value. | |
| 595 | */ | |
| 596 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 597 | assert key != null; | |
| 598 | assert property != null; | |
| 599 | ||
| 600 | listen( key, property, () -> true ); | |
| 601 | } | |
| 602 | ||
| 603 | /** | |
| 604 | * Binds a read-only property to a value in the preferences. This allows | |
| 605 | * user interface properties to change and the preferences will be | |
| 606 | * synchronized automatically. | |
| 607 | * <p> | |
| 608 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 609 | * application window states are finished before assessing whether property | |
| 610 | * changes should be applied. Without this, exiting the application while the | |
| 611 | * window is maximized would persist the window's maximum dimensions, | |
| 612 | * preventing restoration to its prior, non-maximum size. | |
| 613 | * | |
| 614 | * @param key The value to bind to the internal key property. | |
| 615 | * @param property The external property value that sets the internal value. | |
| 616 | * @param enabled Indicates whether property changes should be applied. | |
| 617 | */ | |
| 618 | public <T> void listen( | |
| 619 | final Key key, | |
| 620 | final ReadOnlyProperty<T> property, | |
| 621 | final BooleanSupplier enabled ) { | |
| 622 | assert key != null; | |
| 623 | assert property != null; | |
| 624 | assert enabled != null; | |
| 625 | ||
| 626 | property.addListener( | |
| 627 | ( _, _, n ) -> runLater( () -> { | |
| 627 | 628 | if( enabled.getAsBoolean() ) { |
| 628 | 629 | valuesProperty( key ).setValue( n ); |
| 9 | 9 | import static com.keenwrite.io.SysFile.normalize; |
| 10 | 10 | import static com.keenwrite.typesetting.Typesetter.Mutator; |
| 11 | import static com.keenwrite.util.Strings.sanitize; | |
| 11 | 12 | import static java.nio.charset.StandardCharsets.UTF_8; |
| 12 | 13 | import static java.nio.file.Files.deleteIfExists; |
| ... | ||
| 64 | 65 | final var rWorkDir = normalize( context.getRWorkingDir() ); |
| 65 | 66 | clue( "Main.status.typeset.setting", "r-work", rWorkDir ); |
| 67 | ||
| 68 | final var enableMode = sanitize( context.getEnableMode() ); | |
| 69 | clue( "Main.status.typeset.setting", "mode", enableMode ); | |
| 66 | 70 | |
| 67 | 71 | final var autoRemove = context.getAutoRemove(); |
| ... | ||
| 76 | 80 | .with( Mutator::setCacheDir, cacheDir ) |
| 77 | 81 | .with( Mutator::setFontDir, fontDir ) |
| 82 | .with( Mutator::setEnableMode, enableMode ) | |
| 78 | 83 | .with( Mutator::setAutoRemove, autoRemove ) |
| 79 | 84 | .build(); |
| 80 | ||
| 81 | typesetter.typeset(); | |
| 82 | 85 | |
| 83 | // Smote the temporary file after typesetting the document. | |
| 84 | if( typesetter.autoRemove() ) { | |
| 85 | deleteIfExists( document ); | |
| 86 | try { | |
| 87 | typesetter.typeset(); | |
| 88 | } | |
| 89 | finally { | |
| 90 | // Smote the temporary file after typesetting the document. | |
| 91 | if( typesetter.autoRemove() ) { | |
| 92 | deleteIfExists( document ); | |
| 93 | } | |
| 86 | 94 | } |
| 87 | 95 | } catch( final Exception ex ) { |
| 32 | 32 | import static com.keenwrite.io.SysFile.toFile; |
| 33 | 33 | import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate; |
| 34 | ||
| 35 | /** | |
| 36 | * Provides a context for configuring a chain of {@link Processor} instances. | |
| 37 | */ | |
| 38 | public final class ProcessorContext { | |
| 39 | ||
| 40 | private final Mutator mMutator; | |
| 41 | ||
| 42 | /** | |
| 43 | * Determines the file type from the path extension. This should only be | |
| 44 | * called when it is known that the file type won't be a definition file | |
| 45 | * (e.g., YAML or other definition source), but rather an editable file | |
| 46 | * (e.g., Markdown, R Markdown, etc.). | |
| 47 | * | |
| 48 | * @param path The path with a file name extension. | |
| 49 | * @return The FileType for the given path. | |
| 50 | */ | |
| 51 | private static FileType lookup( final Path path ) { | |
| 52 | assert path != null; | |
| 53 | ||
| 54 | final var prefix = GLOB_PREFIX_FILE; | |
| 55 | final var keys = sSettings.getKeys( prefix ); | |
| 56 | ||
| 57 | var found = false; | |
| 58 | var fileType = UNKNOWN; | |
| 59 | ||
| 60 | while( keys.hasNext() && !found ) { | |
| 61 | final var key = keys.next(); | |
| 62 | final var patterns = sSettings.getStringSettingList( key ); | |
| 63 | final var predicate = createFileTypePredicate( patterns ); | |
| 64 | ||
| 65 | if( predicate.test( toFile( path ) ) ) { | |
| 66 | // Remove the EXTENSIONS_PREFIX to get the file name extension mapped | |
| 67 | // to a standard name (as defined in the settings.properties file). | |
| 68 | final String suffix = key.replace( prefix + '.', "" ); | |
| 69 | fileType = FileType.from( suffix ); | |
| 70 | found = true; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | return fileType; | |
| 75 | } | |
| 76 | ||
| 77 | public boolean isExportFormat( final ExportFormat exportFormat ) { | |
| 78 | return mMutator.mExportFormat == exportFormat; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Responsible for populating the instance variables required by the | |
| 83 | * context. | |
| 84 | */ | |
| 85 | public static class Mutator { | |
| 86 | private Path mSourcePath; | |
| 87 | private Path mTargetPath; | |
| 88 | private ExportFormat mExportFormat; | |
| 89 | private Supplier<Boolean> mConcatenate = () -> true; | |
| 90 | private Supplier<String> mChapters = () -> ""; | |
| 91 | ||
| 92 | private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath; | |
| 93 | private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | |
| 94 | ||
| 95 | private Supplier<Map<String, String>> mDefinitions = HashMap::new; | |
| 96 | private Supplier<Map<String, String>> mMetadata = HashMap::new; | |
| 97 | private Supplier<Caret> mCaret = () -> Caret.builder().build(); | |
| 98 | ||
| 99 | private Supplier<Path> mFontDir = () -> getFontDirectory().toPath(); | |
| 100 | ||
| 101 | private Supplier<Path> mImageDir = USER_DIRECTORY::toPath; | |
| 102 | private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | |
| 103 | private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | |
| 104 | ||
| 105 | private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath; | |
| 106 | ||
| 107 | private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | |
| 108 | private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | |
| 109 | ||
| 110 | private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath; | |
| 111 | private Supplier<String> mRScript = () -> ""; | |
| 112 | ||
| 113 | private Supplier<Boolean> mCurlQuotes = () -> true; | |
| 114 | private Supplier<Boolean> mAutoRemove = () -> true; | |
| 115 | ||
| 116 | public void setSourcePath( final Path sourcePath ) { | |
| 117 | assert sourcePath != null; | |
| 118 | mSourcePath = sourcePath; | |
| 119 | } | |
| 120 | ||
| 121 | public void setTargetPath( final Path outputPath ) { | |
| 122 | assert outputPath != null; | |
| 123 | mTargetPath = outputPath; | |
| 124 | } | |
| 125 | ||
| 126 | public void setThemeDir( final Supplier<Path> themeDir ) { | |
| 127 | assert themeDir != null; | |
| 128 | mThemeDir = themeDir; | |
| 129 | } | |
| 130 | ||
| 131 | public void setCacheDir( final Supplier<File> cacheDir ) { | |
| 132 | assert cacheDir != null; | |
| 133 | ||
| 134 | mCacheDir = () -> { | |
| 135 | final var dir = cacheDir.get(); | |
| 136 | ||
| 137 | return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath(); | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | public void setImageDir( final Supplier<File> imageDir ) { | |
| 142 | assert imageDir != null; | |
| 143 | ||
| 144 | mImageDir = () -> { | |
| 145 | final var dir = imageDir.get(); | |
| 146 | ||
| 147 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 148 | }; | |
| 149 | } | |
| 150 | ||
| 151 | public void setImageOrder( final Supplier<String> imageOrder ) { | |
| 152 | assert imageOrder != null; | |
| 153 | mImageOrder = imageOrder; | |
| 154 | } | |
| 155 | ||
| 156 | public void setImageServer( final Supplier<String> imageServer ) { | |
| 157 | assert imageServer != null; | |
| 158 | mImageServer = imageServer; | |
| 159 | } | |
| 160 | ||
| 161 | public void setFontDir( final Supplier<File> fontDir ) { | |
| 162 | assert fontDir != null; | |
| 163 | ||
| 164 | mFontDir = () -> { | |
| 165 | final var dir = fontDir.get(); | |
| 166 | ||
| 167 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 168 | }; | |
| 169 | } | |
| 170 | ||
| 171 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 172 | assert exportFormat != null; | |
| 173 | mExportFormat = exportFormat; | |
| 174 | } | |
| 175 | ||
| 176 | public void setConcatenate( final Supplier<Boolean> concatenate ) { | |
| 177 | mConcatenate = concatenate; | |
| 178 | } | |
| 179 | ||
| 180 | public void setChapters( final Supplier<String> chapters ) { | |
| 181 | mChapters = chapters; | |
| 182 | } | |
| 183 | ||
| 184 | public void setLocale( final Supplier<Locale> locale ) { | |
| 185 | assert locale != null; | |
| 186 | mLocale = locale; | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Sets the list of fully interpolated key-value pairs to use when | |
| 191 | * substituting variable names back into the document as variable values. | |
| 192 | * This uses a {@link Callable} reference so that GUI and command-line | |
| 193 | * usage can insert their respective behaviours. That is, this method | |
| 194 | * prevents coupling the GUI to the CLI. | |
| 195 | * | |
| 196 | * @param supplier Defines how to retrieve the definitions. | |
| 197 | */ | |
| 198 | public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | |
| 199 | assert supplier != null; | |
| 200 | mDefinitions = supplier; | |
| 201 | } | |
| 202 | ||
| 203 | /** | |
| 204 | * Sets metadata to use in the document header. These are made available | |
| 205 | * to the typesetting engine as {@code \documentvariable} values. | |
| 206 | * | |
| 207 | * @param metadata The key/value pairs to publish as document metadata. | |
| 208 | */ | |
| 209 | public void setMetadata( final Supplier<Map<String, String>> metadata ) { | |
| 210 | assert metadata != null; | |
| 211 | mMetadata = metadata.get() == null ? HashMap::new : metadata; | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * Sets document variables to use when building the document. These | |
| 216 | * variables will override existing key/value pairs, or be added as | |
| 217 | * new key/value pairs if not already defined. This allows users to | |
| 218 | * inject variables into the document from the command-line, allowing | |
| 219 | * for dynamic assignment of in-text values when building documents. | |
| 220 | * | |
| 221 | * @param overrides The key/value pairs to add (or override) as variables. | |
| 222 | */ | |
| 223 | public void setOverrides( final Supplier<Map<String, String>> overrides ) { | |
| 224 | assert overrides != null; | |
| 225 | assert mDefinitions != null; | |
| 226 | assert mDefinitions.get() != null; | |
| 227 | ||
| 228 | final var map = overrides.get(); | |
| 229 | ||
| 230 | if( map != null ) { | |
| 231 | mDefinitions.get().putAll( map ); | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Sets the source for deriving the {@link Caret}. Typically, this is | |
| 237 | * the text editor that has focus. | |
| 238 | * | |
| 239 | * @param caret The source for the currently active caret. | |
| 240 | */ | |
| 241 | public void setCaret( final Supplier<Caret> caret ) { | |
| 242 | assert caret != null; | |
| 243 | mCaret = caret; | |
| 244 | } | |
| 245 | ||
| 246 | public void setSigilBegan( final Supplier<String> sigilBegan ) { | |
| 247 | assert sigilBegan != null; | |
| 248 | mSigilBegan = sigilBegan; | |
| 249 | } | |
| 250 | ||
| 251 | public void setSigilEnded( final Supplier<String> sigilEnded ) { | |
| 252 | assert sigilEnded != null; | |
| 253 | mSigilEnded = sigilEnded; | |
| 254 | } | |
| 255 | ||
| 256 | public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | |
| 257 | assert rWorkingDir != null; | |
| 258 | ||
| 259 | mRWorkingDir = rWorkingDir; | |
| 260 | } | |
| 261 | ||
| 262 | public void setRScript( final Supplier<String> rScript ) { | |
| 263 | assert rScript != null; | |
| 264 | mRScript = rScript; | |
| 265 | } | |
| 266 | ||
| 267 | public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) { | |
| 268 | assert curlQuotes != null; | |
| 269 | mCurlQuotes = curlQuotes; | |
| 270 | } | |
| 271 | ||
| 272 | public void setAutoRemove( final Supplier<Boolean> autoRemove ) { | |
| 273 | assert autoRemove != null; | |
| 274 | mAutoRemove = autoRemove; | |
| 275 | } | |
| 276 | ||
| 277 | private boolean isExportFormat( final ExportFormat format ) { | |
| 278 | return mExportFormat == format; | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | public static GenericBuilder<Mutator, ProcessorContext> builder() { | |
| 283 | return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * Creates a new context for use by the {@link ProcessorFactory} when | |
| 288 | * instantiating new {@link Processor} instances. Although all the | |
| 289 | * parameters are required, not all {@link Processor} instances will use | |
| 290 | * all parameters. | |
| 291 | */ | |
| 292 | private ProcessorContext( final Mutator mutator ) { | |
| 293 | assert mutator != null; | |
| 294 | ||
| 295 | mMutator = mutator; | |
| 296 | } | |
| 297 | ||
| 298 | public Path getSourcePath() { | |
| 299 | return mMutator.mSourcePath; | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * Answers what type of input document is to be processed. | |
| 304 | * | |
| 305 | * @return The input document's {@link MediaType}. | |
| 306 | */ | |
| 307 | public MediaType getSourceType() { | |
| 308 | return MediaTypeExtension.fromPath( mMutator.mSourcePath ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Fully qualified file name to use when exporting (e.g., document.pdf). | |
| 313 | * | |
| 314 | * @return Full path to a file name. | |
| 315 | */ | |
| 316 | public Path getTargetPath() { | |
| 317 | return mMutator.mTargetPath; | |
| 318 | } | |
| 319 | ||
| 320 | public ExportFormat getExportFormat() { | |
| 321 | return mMutator.mExportFormat; | |
| 322 | } | |
| 323 | ||
| 324 | public Locale getLocale() { | |
| 325 | return mMutator.mLocale.get(); | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * Returns the variable map of definitions, without interpolation. | |
| 330 | * | |
| 331 | * @return A map to help dereference variables. | |
| 332 | */ | |
| 333 | public Map<String, String> getDefinitions() { | |
| 334 | return mMutator.mDefinitions.get(); | |
| 335 | } | |
| 336 | ||
| 337 | /** | |
| 338 | * Returns the variable map of definitions, with interpolation. | |
| 339 | * | |
| 340 | * @return A map to help dereference variables. | |
| 341 | */ | |
| 342 | public InterpolatingMap getInterpolatedDefinitions() { | |
| 343 | return new InterpolatingMap( | |
| 344 | createDefinitionKeyOperator(), getDefinitions() | |
| 345 | ).interpolate(); | |
| 346 | } | |
| 347 | ||
| 348 | public Map<String, String> getMetadata() { | |
| 349 | return mMutator.mMetadata.get(); | |
| 350 | } | |
| 351 | ||
| 352 | /** | |
| 353 | * Returns the current caret position in the document being edited and is | |
| 354 | * always up-to-date. | |
| 355 | * | |
| 356 | * @return Caret position in the document. | |
| 357 | */ | |
| 358 | public Supplier<Caret> getCaret() { | |
| 359 | return mMutator.mCaret; | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Returns the directory that contains the file being edited. When | |
| 364 | * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | |
| 365 | * {@code null}. This will get absolute path to the file before trying to | |
| 366 | * get te parent path, which should always be a valid path. In the unlikely | |
| 367 | * event that the base path cannot be determined by the path alone, the | |
| 368 | * default user directory is returned. This is necessary for the creation | |
| 369 | * of new files. | |
| 370 | * | |
| 371 | * @return Path to the directory containing a file being edited, or the | |
| 372 | * default user directory if the base path cannot be determined. | |
| 373 | */ | |
| 374 | public Path getBaseDir() { | |
| 375 | final var path = getSourcePath().toAbsolutePath().getParent(); | |
| 376 | return path == null ? DEFAULT_DIRECTORY : path; | |
| 377 | } | |
| 378 | ||
| 379 | FileType getSourceFileType() { | |
| 380 | return lookup( getSourcePath() ); | |
| 381 | } | |
| 382 | ||
| 383 | public Path getThemeDir() { | |
| 384 | return mMutator.mThemeDir.get(); | |
| 385 | } | |
| 386 | ||
| 387 | public Path getImageDir() { | |
| 388 | return mMutator.mImageDir.get(); | |
| 389 | } | |
| 390 | ||
| 391 | public Path getCacheDir() { | |
| 392 | return mMutator.mCacheDir.get(); | |
| 393 | } | |
| 394 | ||
| 395 | public Iterable<String> getImageOrder() { | |
| 396 | assert mMutator.mImageOrder != null; | |
| 397 | ||
| 398 | final var order = mMutator.mImageOrder.get(); | |
| 399 | final var token = order.contains( "," ) ? ',' : ' '; | |
| 400 | ||
| 401 | return Splitter.on( token ).split( token + order ); | |
| 402 | } | |
| 403 | ||
| 404 | public String getImageServer() { | |
| 405 | return mMutator.mImageServer.get(); | |
| 406 | } | |
| 407 | ||
| 408 | public Path getFontDir() { | |
| 409 | return mMutator.mFontDir.get(); | |
| 34 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 35 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; | |
| 36 | ||
| 37 | /** | |
| 38 | * Provides a context for configuring a chain of {@link Processor} instances. | |
| 39 | */ | |
| 40 | public final class ProcessorContext { | |
| 41 | ||
| 42 | private final Mutator mMutator; | |
| 43 | ||
| 44 | /** | |
| 45 | * Determines the file type from the path extension. This should only be | |
| 46 | * called when it is known that the file type won't be a definition file | |
| 47 | * (e.g., YAML or other definition source), but rather an editable file | |
| 48 | * (e.g., Markdown, R Markdown, etc.). | |
| 49 | * | |
| 50 | * @param path The path with a file name extension. | |
| 51 | * @return The FileType for the given path. | |
| 52 | */ | |
| 53 | private static FileType lookup( final Path path ) { | |
| 54 | assert path != null; | |
| 55 | ||
| 56 | final var prefix = GLOB_PREFIX_FILE; | |
| 57 | final var keys = sSettings.getKeys( prefix ); | |
| 58 | ||
| 59 | var found = false; | |
| 60 | var fileType = UNKNOWN; | |
| 61 | ||
| 62 | while( keys.hasNext() && !found ) { | |
| 63 | final var key = keys.next(); | |
| 64 | final var patterns = sSettings.getStringSettingList( key ); | |
| 65 | final var predicate = createFileTypePredicate( patterns ); | |
| 66 | ||
| 67 | if( predicate.test( toFile( path ) ) ) { | |
| 68 | // Remove the EXTENSIONS_PREFIX to get the file name extension mapped | |
| 69 | // to a standard name (as defined in the settings.properties file). | |
| 70 | final String suffix = key.replace( prefix + '.', "" ); | |
| 71 | fileType = FileType.from( suffix ); | |
| 72 | found = true; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | return fileType; | |
| 77 | } | |
| 78 | ||
| 79 | public boolean isExportFormat( final ExportFormat exportFormat ) { | |
| 80 | return mMutator.mExportFormat == exportFormat; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Responsible for populating the instance variables required by the | |
| 85 | * context. | |
| 86 | */ | |
| 87 | public static class Mutator { | |
| 88 | private Path mSourcePath; | |
| 89 | private Path mTargetPath; | |
| 90 | private ExportFormat mExportFormat; | |
| 91 | private Supplier<Boolean> mConcatenate = () -> true; | |
| 92 | private Supplier<String> mChapters = () -> ""; | |
| 93 | ||
| 94 | private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath; | |
| 95 | private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | |
| 96 | ||
| 97 | private Supplier<Map<String, String>> mDefinitions = HashMap::new; | |
| 98 | private Supplier<Map<String, String>> mMetadata = HashMap::new; | |
| 99 | private Supplier<Caret> mCaret = () -> Caret.builder().build(); | |
| 100 | ||
| 101 | private Supplier<Path> mImageDir = USER_DIRECTORY::toPath; | |
| 102 | private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | |
| 103 | private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | |
| 104 | private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath; | |
| 105 | private Supplier<Path> mFontDir = () -> getFontDirectory().toPath(); | |
| 106 | ||
| 107 | private Supplier<String> mEnableMode = () -> ""; | |
| 108 | ||
| 109 | private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | |
| 110 | private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | |
| 111 | ||
| 112 | private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath; | |
| 113 | private Supplier<String> mRScript = () -> ""; | |
| 114 | ||
| 115 | private Supplier<Boolean> mCurlQuotes = () -> true; | |
| 116 | private Supplier<Boolean> mAutoRemove = () -> true; | |
| 117 | ||
| 118 | public void setSourcePath( final Path sourcePath ) { | |
| 119 | assert sourcePath != null; | |
| 120 | mSourcePath = sourcePath; | |
| 121 | } | |
| 122 | ||
| 123 | public void setTargetPath( final Path outputPath ) { | |
| 124 | assert outputPath != null; | |
| 125 | mTargetPath = outputPath; | |
| 126 | } | |
| 127 | ||
| 128 | public void setThemeDir( final Supplier<Path> themeDir ) { | |
| 129 | assert themeDir != null; | |
| 130 | mThemeDir = themeDir; | |
| 131 | } | |
| 132 | ||
| 133 | public void setCacheDir( final Supplier<File> cacheDir ) { | |
| 134 | assert cacheDir != null; | |
| 135 | ||
| 136 | mCacheDir = () -> { | |
| 137 | final var dir = cacheDir.get(); | |
| 138 | ||
| 139 | return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath(); | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | public void setImageDir( final Supplier<File> imageDir ) { | |
| 144 | assert imageDir != null; | |
| 145 | ||
| 146 | mImageDir = () -> { | |
| 147 | final var dir = imageDir.get(); | |
| 148 | ||
| 149 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 150 | }; | |
| 151 | } | |
| 152 | ||
| 153 | public void setImageOrder( final Supplier<String> imageOrder ) { | |
| 154 | assert imageOrder != null; | |
| 155 | mImageOrder = imageOrder; | |
| 156 | } | |
| 157 | ||
| 158 | public void setImageServer( final Supplier<String> imageServer ) { | |
| 159 | assert imageServer != null; | |
| 160 | mImageServer = imageServer; | |
| 161 | } | |
| 162 | ||
| 163 | public void setFontDir( final Supplier<File> fontDir ) { | |
| 164 | assert fontDir != null; | |
| 165 | ||
| 166 | mFontDir = () -> { | |
| 167 | final var dir = fontDir.get(); | |
| 168 | ||
| 169 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | public void setEnableMode( final Supplier<String> enableMode ) { | |
| 174 | assert enableMode != null; | |
| 175 | mEnableMode = enableMode; | |
| 176 | } | |
| 177 | ||
| 178 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 179 | assert exportFormat != null; | |
| 180 | mExportFormat = exportFormat; | |
| 181 | } | |
| 182 | ||
| 183 | public void setConcatenate( final Supplier<Boolean> concatenate ) { | |
| 184 | mConcatenate = concatenate; | |
| 185 | } | |
| 186 | ||
| 187 | public void setChapters( final Supplier<String> chapters ) { | |
| 188 | mChapters = chapters; | |
| 189 | } | |
| 190 | ||
| 191 | public void setLocale( final Supplier<Locale> locale ) { | |
| 192 | assert locale != null; | |
| 193 | mLocale = locale; | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Sets the list of fully interpolated key-value pairs to use when | |
| 198 | * substituting variable names back into the document as variable values. | |
| 199 | * This uses a {@link Callable} reference so that GUI and command-line | |
| 200 | * usage can insert their respective behaviours. That is, this method | |
| 201 | * prevents coupling the GUI to the CLI. | |
| 202 | * | |
| 203 | * @param supplier Defines how to retrieve the definitions. | |
| 204 | */ | |
| 205 | public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | |
| 206 | assert supplier != null; | |
| 207 | mDefinitions = supplier; | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Sets metadata to use in the document header. These are made available | |
| 212 | * to the typesetting engine as {@code \documentvariable} values. | |
| 213 | * | |
| 214 | * @param metadata The key/value pairs to publish as document metadata. | |
| 215 | */ | |
| 216 | public void setMetadata( final Supplier<Map<String, String>> metadata ) { | |
| 217 | assert metadata != null; | |
| 218 | mMetadata = metadata.get() == null ? HashMap::new : metadata; | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Sets document variables to use when building the document. These | |
| 223 | * variables will override existing key/value pairs, or be added as | |
| 224 | * new key/value pairs if not already defined. This allows users to | |
| 225 | * inject variables into the document from the command-line, allowing | |
| 226 | * for dynamic assignment of in-text values when building documents. | |
| 227 | * | |
| 228 | * @param overrides The key/value pairs to add (or override) as variables. | |
| 229 | */ | |
| 230 | public void setOverrides( final Supplier<Map<String, String>> overrides ) { | |
| 231 | assert overrides != null; | |
| 232 | assert mDefinitions != null; | |
| 233 | assert mDefinitions.get() != null; | |
| 234 | ||
| 235 | final var map = overrides.get(); | |
| 236 | ||
| 237 | if( map != null ) { | |
| 238 | mDefinitions.get().putAll( map ); | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Sets the source for deriving the {@link Caret}. Typically, this is | |
| 244 | * the text editor that has focus. | |
| 245 | * | |
| 246 | * @param caret The source for the currently active caret. | |
| 247 | */ | |
| 248 | public void setCaret( final Supplier<Caret> caret ) { | |
| 249 | assert caret != null; | |
| 250 | mCaret = caret; | |
| 251 | } | |
| 252 | ||
| 253 | public void setSigilBegan( final Supplier<String> sigilBegan ) { | |
| 254 | assert sigilBegan != null; | |
| 255 | mSigilBegan = sigilBegan; | |
| 256 | } | |
| 257 | ||
| 258 | public void setSigilEnded( final Supplier<String> sigilEnded ) { | |
| 259 | assert sigilEnded != null; | |
| 260 | mSigilEnded = sigilEnded; | |
| 261 | } | |
| 262 | ||
| 263 | public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | |
| 264 | assert rWorkingDir != null; | |
| 265 | mRWorkingDir = rWorkingDir; | |
| 266 | } | |
| 267 | ||
| 268 | public void setRScript( final Supplier<String> rScript ) { | |
| 269 | assert rScript != null; | |
| 270 | mRScript = rScript; | |
| 271 | } | |
| 272 | ||
| 273 | public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) { | |
| 274 | assert curlQuotes != null; | |
| 275 | mCurlQuotes = curlQuotes; | |
| 276 | } | |
| 277 | ||
| 278 | public void setAutoRemove( final Supplier<Boolean> autoRemove ) { | |
| 279 | assert autoRemove != null; | |
| 280 | mAutoRemove = autoRemove; | |
| 281 | } | |
| 282 | ||
| 283 | private boolean isExportFormat( final ExportFormat format ) { | |
| 284 | return mExportFormat == format; | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | public static GenericBuilder<Mutator, ProcessorContext> builder() { | |
| 289 | return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | |
| 290 | } | |
| 291 | ||
| 292 | /** | |
| 293 | * Creates a new context for use by the {@link ProcessorFactory} when | |
| 294 | * instantiating new {@link Processor} instances. Although all the | |
| 295 | * parameters are required, not all {@link Processor} instances will use | |
| 296 | * all parameters. | |
| 297 | */ | |
| 298 | private ProcessorContext( final Mutator mutator ) { | |
| 299 | assert mutator != null; | |
| 300 | ||
| 301 | mMutator = mutator; | |
| 302 | } | |
| 303 | ||
| 304 | public Path getSourcePath() { | |
| 305 | return mMutator.mSourcePath; | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Answers what type of input document is to be processed. | |
| 310 | * | |
| 311 | * @return The input document's {@link MediaType}. | |
| 312 | */ | |
| 313 | public MediaType getSourceType() { | |
| 314 | return MediaTypeExtension.fromPath( mMutator.mSourcePath ); | |
| 315 | } | |
| 316 | ||
| 317 | /** | |
| 318 | * Fully qualified file name to use when exporting (e.g., document.pdf). | |
| 319 | * | |
| 320 | * @return Full path to a file name. | |
| 321 | */ | |
| 322 | public Path getTargetPath() { | |
| 323 | return mMutator.mTargetPath; | |
| 324 | } | |
| 325 | ||
| 326 | public ExportFormat getExportFormat() { | |
| 327 | return mMutator.mExportFormat; | |
| 328 | } | |
| 329 | ||
| 330 | public Locale getLocale() { | |
| 331 | return mMutator.mLocale.get(); | |
| 332 | } | |
| 333 | ||
| 334 | /** | |
| 335 | * Returns the variable map of definitions, without interpolation. | |
| 336 | * | |
| 337 | * @return A map to help dereference variables. | |
| 338 | */ | |
| 339 | public Map<String, String> getDefinitions() { | |
| 340 | return mMutator.mDefinitions.get(); | |
| 341 | } | |
| 342 | ||
| 343 | /** | |
| 344 | * Returns the variable map of definitions, with interpolation. | |
| 345 | * | |
| 346 | * @return A map to help dereference variables. | |
| 347 | */ | |
| 348 | public InterpolatingMap getInterpolatedDefinitions() { | |
| 349 | return new InterpolatingMap( | |
| 350 | createDefinitionKeyOperator(), getDefinitions() | |
| 351 | ).interpolate(); | |
| 352 | } | |
| 353 | ||
| 354 | public Map<String, String> getMetadata() { | |
| 355 | return mMutator.mMetadata.get(); | |
| 356 | } | |
| 357 | ||
| 358 | /** | |
| 359 | * Returns the current caret position in the document being edited and is | |
| 360 | * always up-to-date. | |
| 361 | * | |
| 362 | * @return Caret position in the document. | |
| 363 | */ | |
| 364 | public Supplier<Caret> getCaret() { | |
| 365 | return mMutator.mCaret; | |
| 366 | } | |
| 367 | ||
| 368 | /** | |
| 369 | * Returns the directory that contains the file being edited. When | |
| 370 | * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | |
| 371 | * {@code null}. This will get absolute path to the file before trying to | |
| 372 | * get te parent path, which should always be a valid path. In the unlikely | |
| 373 | * event that the base path cannot be determined by the path alone, the | |
| 374 | * default user directory is returned. This is necessary for the creation | |
| 375 | * of new files. | |
| 376 | * | |
| 377 | * @return Path to the directory containing a file being edited, or the | |
| 378 | * default user directory if the base path cannot be determined. | |
| 379 | */ | |
| 380 | public Path getBaseDir() { | |
| 381 | final var path = getSourcePath().toAbsolutePath().getParent(); | |
| 382 | return path == null ? DEFAULT_DIRECTORY : path; | |
| 383 | } | |
| 384 | ||
| 385 | FileType getSourceFileType() { | |
| 386 | return lookup( getSourcePath() ); | |
| 387 | } | |
| 388 | ||
| 389 | public Path getThemeDir() { | |
| 390 | return mMutator.mThemeDir.get(); | |
| 391 | } | |
| 392 | ||
| 393 | public Path getImageDir() { | |
| 394 | return mMutator.mImageDir.get(); | |
| 395 | } | |
| 396 | ||
| 397 | public Path getCacheDir() { | |
| 398 | return mMutator.mCacheDir.get(); | |
| 399 | } | |
| 400 | ||
| 401 | public Iterable<String> getImageOrder() { | |
| 402 | assert mMutator.mImageOrder != null; | |
| 403 | ||
| 404 | final var order = mMutator.mImageOrder.get(); | |
| 405 | final var token = order.contains( "," ) ? ',' : ' '; | |
| 406 | ||
| 407 | return Splitter.on( token ).split( token + order ); | |
| 408 | } | |
| 409 | ||
| 410 | public String getImageServer() { | |
| 411 | return mMutator.mImageServer.get(); | |
| 412 | } | |
| 413 | ||
| 414 | public Path getFontDir() { | |
| 415 | return mMutator.mFontDir.get(); | |
| 416 | } | |
| 417 | ||
| 418 | public String getEnableMode() { | |
| 419 | final var processor = new VariableProcessor( IDENTITY, this ); | |
| 420 | final var needles = processor.getDefinitions(); | |
| 421 | final var haystack = mMutator.mEnableMode.get(); | |
| 422 | final var result = replace( haystack, needles ); | |
| 423 | ||
| 424 | // If no replacement was made, then the mode variable isn't set. | |
| 425 | return result.equals( haystack ) ? "" : result; | |
| 410 | 426 | } |
| 411 | 427 |
| 2 | 2 | package com.keenwrite.processors.text; |
| 3 | 3 | |
| 4 | import org.apache.commons.lang3.StringUtils; | |
| 5 | ||
| 6 | 4 | import java.util.Map; |
| 7 | 5 | |
| 8 | import static org.apache.commons.lang3.StringUtils.replaceEach; | |
| 6 | import static com.keenwrite.util.Strings.replaceEach; | |
| 9 | 7 | |
| 10 | 8 | /** |
| 11 | * Replaces text using a brute-force | |
| 12 | * {@link StringUtils#replaceEach(String, String[], String[])}} method. | |
| 9 | * Replaces text using a brute-force replacement method. | |
| 13 | 10 | */ |
| 14 | 11 | public class StringUtilsReplacer extends AbstractTextReplacer { |
| 15 | 12 | |
| 16 | 13 | /** |
| 17 | 14 | * Default (empty) constructor. |
| 18 | 15 | */ |
| 19 | protected StringUtilsReplacer() { } | |
| 16 | protected StringUtilsReplacer() {} | |
| 20 | 17 | |
| 21 | 18 | @Override |
| 33 | 33 | |
| 34 | 34 | private static final String TYPESETTER_VERSION = |
| 35 | TYPESETTER_EXE + " --version > /dev/null"; | |
| 35 | STR."\{TYPESETTER_EXE} --version > /dev/null"; | |
| 36 | 36 | |
| 37 | 37 | public GuestTypesetter( final Mutator mutator ) { |
| ... | ||
| 100 | 100 | manager.run( |
| 101 | 101 | input -> gobble( input, s -> exitCode.append( s.trim() ) ), |
| 102 | TYPESETTER_VERSION + "; echo $?" | |
| 102 | STR."\{TYPESETTER_VERSION}; echo $?" | |
| 103 | 103 | ); |
| 104 | 104 | |
| 28 | 28 | * ({@link GuestTypesetter}). |
| 29 | 29 | */ |
| 30 | @SuppressWarnings( "SpellCheckingInspection" ) | |
| 30 | 31 | public class Typesetter { |
| 31 | 32 | /** |
| ... | ||
| 45 | 46 | private Path mCacheDir = USER_CACHE_DIR.toPath(); |
| 46 | 47 | private Path mFontDir = getFontDirectory().toPath(); |
| 48 | private String mEnableMode = ""; | |
| 47 | 49 | private boolean mAutoRemove; |
| 48 | 50 | |
| ... | ||
| 88 | 90 | public void setFontDir( final Path fontDir ) { |
| 89 | 91 | mFontDir = fontDir; |
| 92 | } | |
| 93 | ||
| 94 | public void setEnableMode( final String enableMode ) { | |
| 95 | mEnableMode = enableMode; | |
| 90 | 96 | } |
| 91 | 97 | |
| ... | ||
| 120 | 126 | public Path getFontDir() { |
| 121 | 127 | return mFontDir; |
| 128 | } | |
| 129 | ||
| 130 | public String getEnableMode() { | |
| 131 | return mEnableMode; | |
| 122 | 132 | } |
| 123 | 133 | |
| ... | ||
| 153 | 163 | |
| 154 | 164 | final var outputPath = getTargetPath(); |
| 155 | final var prefix = "Main.status.typeset"; | |
| 165 | final var prefix = "Main.status.typeset."; | |
| 156 | 166 | |
| 157 | clue( prefix + ".began", outputPath ); | |
| 167 | clue( STR."\{prefix}began", outputPath ); | |
| 158 | 168 | |
| 159 | 169 | final var time = currentTimeMillis(); |
| 160 | 170 | final var success = typesetter.call(); |
| 161 | final var suffix = success ? ".success" : ".failure"; | |
| 171 | final var suffix = success ? "success" : "failure"; | |
| 162 | 172 | |
| 163 | clue( prefix + ".ended" + suffix, outputPath, since( time ) ); | |
| 173 | clue( STR."\{prefix}ended.\{suffix}", outputPath, since( time ) ); | |
| 164 | 174 | } |
| 165 | 175 | |
| 166 | 176 | /** |
| 167 | * Generates the command-line arguments used to invoke the typesetter. | |
| 177 | * Generates command-line arguments used to invoke the typesetter. | |
| 168 | 178 | */ |
| 169 | 179 | @SuppressWarnings( "SpellCheckingInspection" ) |
| ... | ||
| 184 | 194 | args.add( format( "--result='%s'", targetPath ) ); |
| 185 | 195 | args.add( sourcePath ); |
| 196 | ||
| 197 | final var enableMode = getEnableMode(); | |
| 198 | ||
| 199 | if( !enableMode.isBlank() ) { | |
| 200 | args.add( format( "--mode=%s", enableMode ) ); | |
| 201 | } | |
| 186 | 202 | |
| 187 | 203 | return args; |
| 188 | 204 | } |
| 189 | 205 | |
| 190 | @SuppressWarnings( "SpellCheckingInspection" ) | |
| 191 | 206 | List<String> commonOptions() { |
| 192 | 207 | final var args = new LinkedList<String>(); |
| ... | ||
| 225 | 240 | protected Path getFontDir() { |
| 226 | 241 | return mMutator.getFontDir(); |
| 242 | } | |
| 243 | ||
| 244 | protected String getEnableMode() { | |
| 245 | return mMutator.getEnableMode(); | |
| 227 | 246 | } |
| 228 | 247 | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.typesetting.containerization; |
| 3 | 6 | |
| ... | ||
| 14 | 17 | import static com.keenwrite.events.StatusEvent.clue; |
| 15 | 18 | import static com.keenwrite.io.SysFile.toFile; |
| 19 | import static com.keenwrite.util.SystemUtils.IS_OS_WINDOWS; | |
| 16 | 20 | import static java.lang.String.format; |
| 17 | 21 | import static java.lang.String.join; |
| 18 | 22 | import static java.lang.System.arraycopy; |
| 19 | 23 | import static java.util.Arrays.copyOf; |
| 20 | import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS; | |
| 21 | 24 | |
| 22 | 25 | /** |
| 16 | 16 | import static com.keenwrite.Messages.get; |
| 17 | 17 | import static com.keenwrite.events.Bus.register; |
| 18 | import static org.apache.commons.lang3.SystemUtils.*; | |
| 18 | import static com.keenwrite.util.SystemUtils.*; | |
| 19 | 19 | |
| 20 | 20 | /** |
| 20 | 20 | import static com.keenwrite.events.StatusEvent.clue; |
| 21 | 21 | import static com.keenwrite.io.SysFile.toFile; |
| 22 | import static com.keenwrite.io.downloads.DownloadManager.downloadAsync; | |
| 23 | import static com.keenwrite.io.downloads.DownloadManager.toFilename; | |
| 22 | 24 | |
| 23 | 25 | /** |
| ... | ||
| 33 | 35 | |
| 34 | 36 | public AbstractDownloadPane() { |
| 35 | mUri = getUri( getPrefix() + ".download.link.url" ); | |
| 37 | mUri = getUri( STR."\{getPrefix()}.download.link.url" ); | |
| 36 | 38 | mFilename = toFilename( mUri ); |
| 37 | 39 | final var directory = USER_DATA_DIR; |
| 38 | 40 | mTarget = toFile( directory.resolve( mFilename ) ); |
| 39 | final var source = labelf( getPrefix() + ".paths", mFilename, directory ); | |
| 40 | mStatus = labelf( getPrefix() + STATUS + ".progress", 0, 0 ); | |
| 41 | final var source = labelf( STR."\{getPrefix()}.paths", mFilename, directory ); | |
| 42 | mStatus = labelf( STR."\{getPrefix()}\{STATUS}.progress", 0, 0 ); | |
| 41 | 43 | |
| 42 | 44 | final var border = new BorderPane(); |
| ... | ||
| 71 | 73 | final var suffix = checksumOk ? ".ok" : ".no"; |
| 72 | 74 | |
| 73 | updateStatus( STATUS + ".checksum" + suffix, mFilename ); | |
| 75 | updateStatus( STR."\{STATUS}.checksum\{suffix}", mFilename ); | |
| 74 | 76 | disableNext( !checksumOk ); |
| 75 | 77 | } |
| ... | ||
| 85 | 87 | properties.put( threadName, task ); |
| 86 | 88 | |
| 87 | task.setOnSucceeded( e -> onDownloadSucceeded( threadName, properties ) ); | |
| 88 | task.setOnFailed( e -> onDownloadFailed( threadName, properties ) ); | |
| 89 | task.setOnCancelled( e -> onDownloadFailed( threadName, properties ) ); | |
| 89 | task.setOnSucceeded( _ -> onDownloadSucceeded( threadName, properties ) ); | |
| 90 | task.setOnFailed( _ -> onDownloadFailed( threadName, properties ) ); | |
| 91 | task.setOnCancelled( _ -> onDownloadFailed( threadName, properties ) ); | |
| 90 | 92 | } |
| 91 | 93 | } |
| 92 | 94 | |
| 93 | 95 | protected void updateProperties( |
| 94 | 96 | final ObservableMap<Object, Object> properties ) { |
| 95 | 97 | } |
| 96 | 98 | |
| 97 | 99 | @Override |
| 98 | 100 | protected String getHeaderKey() { |
| 99 | return getPrefix() + ".header"; | |
| 101 | return STR."\{getPrefix()}.header"; | |
| 100 | 102 | } |
| 101 | 103 | |
| ... | ||
| 110 | 112 | protected void onDownloadSucceeded( |
| 111 | 113 | final String threadName, final ObservableMap<Object, Object> properties ) { |
| 112 | updateStatus( STATUS + ".success" ); | |
| 114 | updateStatus( STR."\{STATUS}.success" ); | |
| 113 | 115 | properties.remove( threadName ); |
| 114 | 116 | disableNext( false ); |
| 115 | 117 | } |
| 116 | 118 | |
| 117 | 119 | protected void onDownloadFailed( |
| 118 | 120 | final String threadName, final ObservableMap<Object, Object> properties ) { |
| 119 | updateStatus( STATUS + ".failure" ); | |
| 121 | updateStatus( STR."\{STATUS}.failure" ); | |
| 120 | 122 | properties.remove( threadName ); |
| 121 | 123 | } |
| 6 | 6 | |
| 7 | 7 | import com.keenwrite.events.HyperlinkOpenEvent; |
| 8 | import com.keenwrite.io.downloads.DownloadManager; | |
| 9 | import com.keenwrite.io.downloads.DownloadManager.ProgressListener; | |
| 10 | 8 | import com.keenwrite.typesetting.containerization.ContainerManager; |
| 11 | 9 | import com.keenwrite.typesetting.containerization.Podman; |
| 12 | 10 | import javafx.animation.Animation; |
| 13 | 11 | import javafx.animation.RotateTransition; |
| 14 | import javafx.concurrent.Task; | |
| 15 | 12 | import javafx.geometry.Insets; |
| 16 | 13 | import javafx.scene.Node; |
| 17 | 14 | import javafx.scene.control.*; |
| 18 | 15 | import javafx.scene.image.ImageView; |
| 19 | 16 | import javafx.scene.layout.BorderPane; |
| 20 | 17 | import javafx.scene.layout.FlowPane; |
| 21 | 18 | import javafx.scene.layout.Pane; |
| 22 | 19 | import org.controlsfx.dialog.Wizard; |
| 23 | 20 | import org.controlsfx.dialog.WizardPane; |
| 24 | ||
| 25 | import java.io.File; | |
| 26 | import java.net.URI; | |
| 27 | import java.nio.file.Paths; | |
| 28 | import java.util.concurrent.Callable; | |
| 29 | 21 | |
| 30 | 22 | import static com.keenwrite.Messages.get; |
| 31 | 23 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; |
| 32 | import static com.keenwrite.io.SysFile.toFile; | |
| 24 | import static com.keenwrite.io.downloads.DownloadManager.createTask; | |
| 25 | import static com.keenwrite.io.downloads.DownloadManager.createThread; | |
| 33 | 26 | import static java.lang.System.lineSeparator; |
| 34 | 27 | import static javafx.animation.Interpolator.LINEAR; |
| ... | ||
| 127 | 120 | |
| 128 | 121 | if( buttonData.equals( NEXT_FORWARD ) && |
| 129 | lookupButton( buttonType ) instanceof Button button ) { | |
| 122 | lookupButton( buttonType ) instanceof Button button ) { | |
| 130 | 123 | return button; |
| 131 | 124 | } |
| ... | ||
| 223 | 216 | |
| 224 | 217 | static Hyperlink hyperlink( final String prefix ) { |
| 225 | final var label = get( prefix + ".lbl" ); | |
| 226 | final var url = get( prefix + ".url" ); | |
| 218 | final var label = get( STR."\{prefix}.lbl" ); | |
| 219 | final var url = get( STR."\{prefix}.url" ); | |
| 227 | 220 | final var link = new Hyperlink( label ); |
| 228 | 221 | |
| 229 | link.setOnAction( e -> browse( url ) ); | |
| 222 | link.setOnAction( _ -> browse( url ) ); | |
| 230 | 223 | link.setTooltip( new Tooltip( url ) ); |
| 231 | 224 | |
| ... | ||
| 249 | 242 | |
| 250 | 243 | thread.start(); |
| 251 | } | |
| 252 | ||
| 253 | static <T> Task<T> createTask( final Callable<T> callable ) { | |
| 254 | return new Task<>() { | |
| 255 | @Override | |
| 256 | protected T call() throws Exception { | |
| 257 | return callable.call(); | |
| 258 | } | |
| 259 | }; | |
| 260 | } | |
| 261 | ||
| 262 | static <T> Thread createThread( final Task<T> task ) { | |
| 263 | final var thread = new Thread( task ); | |
| 264 | thread.setDaemon( true ); | |
| 265 | return thread; | |
| 266 | 244 | } |
| 267 | 245 | |
| ... | ||
| 284 | 262 | node.appendText( text ); |
| 285 | 263 | node.appendText( lineSeparator() ); |
| 286 | } ); | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Downloads a resource to a local file in a separate {@link Thread}. | |
| 291 | * | |
| 292 | * @param uri The resource to download. | |
| 293 | * @param file The destination mTarget for the resource. | |
| 294 | * @param listener Receives updates as the download proceeds. | |
| 295 | */ | |
| 296 | static Task<Void> downloadAsync( | |
| 297 | final URI uri, | |
| 298 | final File file, | |
| 299 | final ProgressListener listener ) { | |
| 300 | final Task<Void> task = createTask( () -> { | |
| 301 | try( final var token = DownloadManager.open( uri ) ) { | |
| 302 | token.download( file, listener ).run(); | |
| 303 | } | |
| 304 | ||
| 305 | return null; | |
| 306 | 264 | } ); |
| 307 | ||
| 308 | createThread( task ).start(); | |
| 309 | return task; | |
| 310 | } | |
| 311 | ||
| 312 | static String toFilename( final URI uri ) { | |
| 313 | return toFile( Paths.get( uri.getPath() ) ).getName(); | |
| 314 | 265 | } |
| 315 | 266 | } |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 1 | 5 | package com.keenwrite.typesetting.installer.panes; |
| 2 | 6 | |
| 3 | 7 | import com.keenwrite.io.CommandNotFoundException; |
| 8 | import com.keenwrite.io.downloads.DownloadManager; | |
| 4 | 9 | import com.keenwrite.typesetting.containerization.ContainerManager; |
| 5 | 10 | import com.keenwrite.typesetting.containerization.StreamProcessor; |
| 11 | import com.keenwrite.util.FailableBiConsumer; | |
| 12 | import javafx.collections.ObservableMap; | |
| 6 | 13 | import javafx.concurrent.Task; |
| 7 | 14 | import javafx.scene.control.TextArea; |
| 8 | 15 | import javafx.scene.layout.BorderPane; |
| 9 | import org.apache.commons.lang3.function.FailableBiConsumer; | |
| 10 | 16 | import org.controlsfx.dialog.Wizard; |
| 11 | 17 | |
| 12 | 18 | import static com.keenwrite.Messages.get; |
| 13 | 19 | import static com.keenwrite.io.StreamGobbler.gobble; |
| 20 | import static com.keenwrite.io.downloads.DownloadManager.createThread; | |
| 14 | 21 | |
| 15 | 22 | /** |
| 16 | 23 | * Responsible for showing the output from running commands against a container |
| 17 | 24 | * manager. There are a few installation steps that run different commands |
| 18 | 25 | * against the installer, which are platform-specific and cannot be merged. |
| 19 | 26 | * Common functionality between them is codified in this class. |
| 20 | 27 | */ |
| 21 | 28 | public abstract class ManagerOutputPane extends InstallerPane { |
| 22 | private final String PROP_EXECUTOR = getClass().getCanonicalName(); | |
| 29 | private final static String PROP_EXECUTOR = | |
| 30 | ManagerOutputPane.class.getCanonicalName(); | |
| 23 | 31 | |
| 24 | 32 | private final String mCorrectKey; |
| ... | ||
| 60 | 68 | return; |
| 61 | 69 | } |
| 62 | ||
| 63 | final Task<Void> task = createTask( () -> { | |
| 64 | mFc.accept( | |
| 65 | mContainer, | |
| 66 | input -> gobble( input, line -> append( mTextArea, line ) ) | |
| 67 | ); | |
| 68 | properties.remove( thread ); | |
| 69 | return null; | |
| 70 | } ); | |
| 71 | ||
| 72 | task.setOnSucceeded( event -> { | |
| 73 | append( mTextArea, get( mCorrectKey ) ); | |
| 74 | properties.remove( thread ); | |
| 75 | disableNext( false ); | |
| 76 | } ); | |
| 77 | task.setOnFailed( event -> append( mTextArea, get( mMissingKey ) ) ); | |
| 78 | task.setOnCancelled( event -> append( mTextArea, get( mMissingKey ) ) ); | |
| 79 | 70 | |
| 71 | final var task = createTask( properties, thread ); | |
| 80 | 72 | final var executor = createThread( task ); |
| 73 | ||
| 81 | 74 | properties.put( PROP_EXECUTOR, executor ); |
| 82 | 75 | executor.start(); |
| 83 | 76 | } catch( final Exception e ) { |
| 84 | 77 | throw new RuntimeException( e ); |
| 85 | 78 | } |
| 79 | } | |
| 80 | ||
| 81 | private Task<Void> createTask( | |
| 82 | final ObservableMap<Object, Object> properties, | |
| 83 | final Object thread ) { | |
| 84 | final Task<Void> task = DownloadManager.createTask( () -> { | |
| 85 | mFc.accept( | |
| 86 | mContainer, | |
| 87 | input -> gobble( input, line -> append( mTextArea, line ) ) | |
| 88 | ); | |
| 89 | properties.remove( thread ); | |
| 90 | return null; | |
| 91 | } ); | |
| 92 | ||
| 93 | task.setOnSucceeded( _ -> { | |
| 94 | append( mTextArea, get( mCorrectKey ) ); | |
| 95 | properties.remove( thread ); | |
| 96 | disableNext( false ); | |
| 97 | } ); | |
| 98 | task.setOnFailed( _ -> append( mTextArea, get( mMissingKey ) ) ); | |
| 99 | task.setOnCancelled( _ -> append( mTextArea, get( mMissingKey ) ) ); | |
| 100 | return task; | |
| 86 | 101 | } |
| 87 | 102 | } |
| 14 | 14 | import javafx.scene.layout.HBox; |
| 15 | 15 | import javafx.scene.layout.VBox; |
| 16 | import org.jetbrains.annotations.NotNull; | |
| 17 | 16 | |
| 18 | 17 | import static com.keenwrite.Messages.get; |
| 19 | 18 | import static com.keenwrite.Messages.getInt; |
| 19 | import static com.keenwrite.util.SystemUtils.IS_OS_MAC; | |
| 20 | 20 | import static java.lang.String.format; |
| 21 | import static org.apache.commons.lang3.SystemUtils.IS_OS_MAC; | |
| 22 | 21 | |
| 23 | 22 | public final class UnixManagerInstallPane extends InstallerPane { |
| ... | ||
| 88 | 87 | final var node = super.createButtonBar(); |
| 89 | 88 | final var layout = new BorderPane(); |
| 90 | final var copyButton = button( PREFIX + ".copy.began" ); | |
| 89 | final var copyButton = button( STR."\{PREFIX}.copy.began" ); | |
| 91 | 90 | |
| 92 | 91 | // Change the label to indicate clipboard is updated. |
| 93 | copyButton.setOnAction( event -> { | |
| 92 | copyButton.setOnAction( _ -> { | |
| 94 | 93 | SystemClipboard.write( mCommands.getText() ); |
| 95 | copyButton.setText( get( PREFIX + ".copy.ended" ) ); | |
| 94 | copyButton.setText( get( STR."\{PREFIX}.copy.ended" ) ); | |
| 96 | 95 | } ); |
| 97 | 96 | |
| ... | ||
| 109 | 108 | @Override |
| 110 | 109 | protected String getHeaderKey() { |
| 111 | return PREFIX + ".header"; | |
| 110 | return STR."\{PREFIX}.header"; | |
| 112 | 111 | } |
| 113 | 112 | |
| 114 | 113 | private record UnixOsCommand( String name, String command ) |
| 115 | 114 | implements Comparable<UnixOsCommand> { |
| 116 | 115 | @Override |
| 117 | public int compareTo( | |
| 118 | final @NotNull UnixOsCommand other ) { | |
| 116 | public int compareTo( final UnixOsCommand other ) { | |
| 119 | 117 | return toString().compareToIgnoreCase( other.toString() ); |
| 120 | 118 | } |
| ... | ||
| 136 | 134 | final var comboBox = new ComboBox<UnixOsCommand>(); |
| 137 | 135 | final var items = comboBox.getItems(); |
| 138 | final var prefix = PREFIX + ".command"; | |
| 139 | final var distros = getInt( prefix + ".distros", 14 ); | |
| 136 | final var prefix = STR."\{PREFIX}.command"; | |
| 137 | final var distros = getInt( STR."\{prefix}.distros", 14 ); | |
| 140 | 138 | |
| 141 | 139 | for( int i = 1; i <= distros; i++ ) { |
| 142 | 140 | final var suffix = format( ".%02d", i ); |
| 143 | final var name = get( prefix + ".os.name" + suffix ); | |
| 144 | final var command = get( prefix + ".os.text" + suffix ); | |
| 141 | final var name = get( STR."\{prefix}.os.name\{suffix}" ); | |
| 142 | final var command = get( STR."\{prefix}.os.text\{suffix}" ); | |
| 145 | 143 | |
| 146 | 144 | items.add( new UnixOsCommand( name, command ) ); |
| 14 | 14 | |
| 15 | 15 | import static com.keenwrite.Messages.get; |
| 16 | import static com.keenwrite.io.downloads.DownloadManager.createTask; | |
| 17 | import static com.keenwrite.io.downloads.DownloadManager.createThread; | |
| 16 | 18 | |
| 17 | 19 | /** |
| ... | ||
| 39 | 41 | |
| 40 | 42 | final var titledPane = titledPane( "Output", mCommands ); |
| 41 | append( mCommands, get( PREFIX + ".status.running" ) ); | |
| 43 | append( mCommands, get( STR."\{PREFIX}.status.running" ) ); | |
| 42 | 44 | |
| 43 | 45 | final var stepsPane = new VBox(); |
| 44 | 46 | final var steps = stepsPane.getChildren(); |
| 45 | steps.add( label( PREFIX + ".step.0" ) ); | |
| 47 | steps.add( label( STR."\{PREFIX}.step.0" ) ); | |
| 46 | 48 | steps.add( spacer() ); |
| 47 | steps.add( label( PREFIX + ".step.1" ) ); | |
| 48 | steps.add( label( PREFIX + ".step.2" ) ); | |
| 49 | steps.add( label( PREFIX + ".step.3" ) ); | |
| 49 | steps.add( label( STR."\{PREFIX}.step.1" ) ); | |
| 50 | steps.add( label( STR."\{PREFIX}.step.2" ) ); | |
| 51 | steps.add( label( STR."\{PREFIX}.step.3" ) ); | |
| 50 | 52 | steps.add( spacer() ); |
| 51 | 53 | steps.add( titledPane ); |
| ... | ||
| 70 | 72 | |
| 71 | 73 | final var binary = properties.get( WIN_BIN ); |
| 72 | final var key = PREFIX + ".status"; | |
| 74 | final var key = STR."\{PREFIX}.status"; | |
| 73 | 75 | |
| 74 | 76 | if( binary instanceof File exe ) { |
| 75 | 77 | final var task = createTask( () -> { |
| 76 | 78 | final var exit = mContainer.install( exe ); |
| 77 | 79 | |
| 78 | 80 | // Remove the installer after installation is finished. |
| 79 | 81 | properties.remove( thread ); |
| 80 | 82 | |
| 81 | 83 | final var msg = exit == 0 |
| 82 | ? get( key + ".success" ) | |
| 83 | : get( key + ".failure", exit ); | |
| 84 | ? get( STR."\{key}.success" ) | |
| 85 | : get( STR."\{key}.failure", exit ); | |
| 84 | 86 | |
| 85 | 87 | append( mCommands, msg ); |
| ... | ||
| 94 | 96 | } |
| 95 | 97 | else { |
| 96 | append( mCommands, get( PREFIX + ".unknown", binary ) ); | |
| 98 | append( mCommands, get( STR."\{PREFIX}.unknown", binary ) ); | |
| 97 | 99 | } |
| 98 | 100 | } |
| 99 | 101 | |
| 100 | 102 | @Override |
| 101 | 103 | public String getHeaderKey() { |
| 102 | return PREFIX + ".header"; | |
| 104 | return STR."\{PREFIX}.header"; | |
| 103 | 105 | } |
| 104 | 106 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.ui.actions; |
| 3 | 6 | |
| ... | ||
| 61 | 64 | addAction( "file.new", _ -> actions.file_new() ), |
| 62 | 65 | addAction( "file.open", _ -> actions.file_open() ), |
| 66 | addAction( "file.open_url", _ -> actions.file_open_url() ), | |
| 63 | 67 | SEPARATOR, |
| 64 | 68 | addAction( "file.close", _ -> actions.file_close() ), |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.actions; | |
| 3 | ||
| 4 | import com.keenwrite.ExportFormat; | |
| 5 | import com.keenwrite.MainPane; | |
| 6 | import com.keenwrite.MainScene; | |
| 7 | import com.keenwrite.commands.ConcatenateCommand; | |
| 8 | import com.keenwrite.editors.TextDefinition; | |
| 9 | import com.keenwrite.editors.TextEditor; | |
| 10 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 11 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 12 | import com.keenwrite.events.CaretMovedEvent; | |
| 13 | import com.keenwrite.events.ExportFailedEvent; | |
| 14 | import com.keenwrite.io.SysFile; | |
| 15 | import com.keenwrite.preferences.Key; | |
| 16 | import com.keenwrite.preferences.PreferencesController; | |
| 17 | import com.keenwrite.preferences.Workspace; | |
| 18 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 19 | import com.keenwrite.search.SearchModel; | |
| 20 | import com.keenwrite.typesetting.Typesetter; | |
| 21 | import com.keenwrite.ui.controls.SearchBar; | |
| 22 | import com.keenwrite.ui.dialogs.ExportDialog; | |
| 23 | import com.keenwrite.ui.dialogs.ExportSettings; | |
| 24 | import com.keenwrite.ui.dialogs.ImageDialog; | |
| 25 | import com.keenwrite.ui.dialogs.LinkDialog; | |
| 26 | import com.keenwrite.ui.explorer.FilePicker; | |
| 27 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 28 | import com.keenwrite.ui.logging.LogView; | |
| 29 | import com.vladsch.flexmark.ast.Link; | |
| 30 | import javafx.concurrent.Service; | |
| 31 | import javafx.concurrent.Task; | |
| 32 | import javafx.scene.control.Alert; | |
| 33 | import javafx.scene.control.Dialog; | |
| 34 | import javafx.stage.Window; | |
| 35 | import javafx.stage.WindowEvent; | |
| 36 | ||
| 37 | import java.io.File; | |
| 38 | import java.nio.file.Path; | |
| 39 | import java.util.List; | |
| 40 | import java.util.Optional; | |
| 41 | ||
| 42 | import static com.keenwrite.Bootstrap.*; | |
| 43 | import static com.keenwrite.ExportFormat.*; | |
| 44 | import static com.keenwrite.Messages.get; | |
| 45 | import static com.keenwrite.constants.Constants.PDF_DEFAULT; | |
| 46 | import static com.keenwrite.constants.Constants.USER_DIRECTORY; | |
| 47 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 48 | import static com.keenwrite.events.StatusEvent.clue; | |
| 49 | import static com.keenwrite.preferences.AppKeys.*; | |
| 50 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 51 | import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType; | |
| 52 | import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*; | |
| 53 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 54 | import static java.nio.file.Files.writeString; | |
| 55 | import static javafx.application.Platform.runLater; | |
| 56 | import static javafx.event.Event.fireEvent; | |
| 57 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 58 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 59 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 60 | ||
| 61 | /** | |
| 62 | * Responsible for abstracting how functionality is mapped to the application. | |
| 63 | * This allows users to customize accelerator keys and will provide pluggable | |
| 64 | * functionality so that different text markup languages can change documents | |
| 65 | * using their respective syntax. | |
| 66 | */ | |
| 67 | public final class GuiCommands { | |
| 68 | private static final String STYLE_SEARCH = "search"; | |
| 69 | ||
| 70 | /** | |
| 71 | * When an action is executed, this is one of the recipients. | |
| 72 | */ | |
| 73 | private final MainPane mMainPane; | |
| 74 | ||
| 75 | private final MainScene mMainScene; | |
| 76 | ||
| 77 | private final LogView mLogView; | |
| 78 | ||
| 79 | /** | |
| 80 | * Tracks finding text in the active document. | |
| 81 | */ | |
| 82 | private final SearchModel mSearchModel; | |
| 83 | ||
| 84 | private boolean mCanTypeset; | |
| 85 | ||
| 86 | /** | |
| 87 | * A {@link Task} can only be run once, so wrap it in a {@link Service} to | |
| 88 | * allow re-launching the typesetting task repeatedly. | |
| 89 | */ | |
| 90 | private Service<Path> mTypesetService; | |
| 91 | ||
| 92 | /** | |
| 93 | * Prevent a race-condition between checking to see if the typesetting task | |
| 94 | * is running and restarting the task itself. | |
| 95 | */ | |
| 96 | private final Object mMutex = new Object(); | |
| 97 | ||
| 98 | public GuiCommands( final MainScene scene, final MainPane pane ) { | |
| 99 | mMainScene = scene; | |
| 100 | mMainPane = pane; | |
| 101 | mLogView = new LogView(); | |
| 102 | mSearchModel = new SearchModel(); | |
| 103 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 104 | final var editor = getActiveTextEditor(); | |
| 105 | ||
| 106 | // Clear highlighted areas before highlighting a new region. | |
| 107 | if( o != null ) { | |
| 108 | editor.unstylize( STYLE_SEARCH ); | |
| 109 | } | |
| 110 | ||
| 111 | if( n != null ) { | |
| 112 | editor.moveTo( n.getStart() ); | |
| 113 | editor.stylize( n, STYLE_SEARCH ); | |
| 114 | } | |
| 115 | } ); | |
| 116 | ||
| 117 | // When the active text editor changes ... | |
| 118 | mMainPane.textEditorProperty().addListener( | |
| 119 | ( c, o, n ) -> { | |
| 120 | // ... update the haystack. | |
| 121 | mSearchModel.search( getActiveTextEditor().getText() ); | |
| 122 | ||
| 123 | // ... update the status bar with the current caret position. | |
| 124 | if( n != null ) { | |
| 125 | final var w = getWorkspace(); | |
| 126 | final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT ); | |
| 127 | ||
| 128 | // ... preserve the most recent document. | |
| 129 | recentDoc.setValue( n.getFile() ); | |
| 130 | CaretMovedEvent.fire( n.getCaret() ); | |
| 131 | } | |
| 132 | } | |
| 133 | ); | |
| 134 | } | |
| 135 | ||
| 136 | public void file_new() { | |
| 137 | getMainPane().newTextEditor(); | |
| 138 | } | |
| 139 | ||
| 140 | public void file_open() { | |
| 141 | pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | |
| 142 | } | |
| 143 | ||
| 144 | public void file_close() { | |
| 145 | getMainPane().close(); | |
| 146 | } | |
| 147 | ||
| 148 | public void file_close_all() { | |
| 149 | getMainPane().closeAll(); | |
| 150 | } | |
| 151 | ||
| 152 | public void file_save() { | |
| 153 | getMainPane().save(); | |
| 154 | } | |
| 155 | ||
| 156 | public void file_save_as() { | |
| 157 | pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | |
| 158 | } | |
| 159 | ||
| 160 | public void file_save_all() { | |
| 161 | getMainPane().saveAll(); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Converts the actively edited file in the given file format. | |
| 166 | * | |
| 167 | * @param format The destination file format. | |
| 168 | */ | |
| 169 | private void file_export( final ExportFormat format ) { | |
| 170 | file_export( format, false ); | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Converts one or more files into the given file format. If {@code dir} | |
| 175 | * is set to true, this will first append all files in the same directory | |
| 176 | * as the actively edited file. | |
| 177 | * | |
| 178 | * @param format The destination file format. | |
| 179 | * @param dir Export all files in the actively edited file's directory. | |
| 180 | */ | |
| 181 | private void file_export( final ExportFormat format, final boolean dir ) { | |
| 182 | final var editor = getMainPane().getTextEditor(); | |
| 183 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 184 | final var exportParent = exported.get().toPath().getParent(); | |
| 185 | final var editorParent = editor.getPath().getParent(); | |
| 186 | final var userHomeParent = USER_DIRECTORY.toPath(); | |
| 187 | final var exportPath = exportParent != null | |
| 188 | ? exportParent | |
| 189 | : editorParent != null | |
| 190 | ? editorParent | |
| 191 | : userHomeParent; | |
| 192 | ||
| 193 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 194 | final var selected = PDF_DEFAULT | |
| 195 | .getName() | |
| 196 | .equals( exported.get().getName() ); | |
| 197 | final var selection = pickFile( | |
| 198 | selected | |
| 199 | ? filename | |
| 200 | : exported.get(), | |
| 201 | exportPath, | |
| 202 | FILE_EXPORT | |
| 203 | ); | |
| 204 | ||
| 205 | selection.ifPresent( files -> file_export( editor, format, files, dir ) ); | |
| 206 | } | |
| 207 | ||
| 208 | private void file_export( | |
| 209 | final TextEditor editor, | |
| 210 | final ExportFormat format, | |
| 211 | final List<File> files, | |
| 212 | final boolean dir ) { | |
| 213 | editor.save(); | |
| 214 | final var main = getMainPane(); | |
| 215 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 216 | ||
| 217 | final var sourceFile = files.get( 0 ); | |
| 218 | final var sourcePath = sourceFile.toPath(); | |
| 219 | final var document = dir ? append( editor ) : editor.getText(); | |
| 220 | final var context = main.createProcessorContext( sourcePath, format ); | |
| 221 | ||
| 222 | final var service = new Service<Path>() { | |
| 223 | @Override | |
| 224 | protected Task<Path> createTask() { | |
| 225 | final var task = new Task<Path>() { | |
| 226 | @Override | |
| 227 | protected Path call() throws Exception { | |
| 228 | final var chain = createProcessors( context ); | |
| 229 | final var export = chain.apply( document ); | |
| 230 | ||
| 231 | // Processors can export binary files. In such cases, processors | |
| 232 | // return null to prevent further processing. | |
| 233 | return export == null | |
| 234 | ? null | |
| 235 | : writeString( sourcePath, export, UTF_8 ); | |
| 236 | } | |
| 237 | }; | |
| 238 | ||
| 239 | task.setOnSucceeded( | |
| 240 | e -> { | |
| 241 | // Remember the exported file name for next time. | |
| 242 | exported.setValue( sourceFile ); | |
| 243 | ||
| 244 | final var result = task.getValue(); | |
| 245 | ||
| 246 | // Binary formats must notify users of success independently. | |
| 247 | if( result != null ) { | |
| 248 | clue( "Main.status.export.success", result ); | |
| 249 | } | |
| 250 | } | |
| 251 | ); | |
| 252 | ||
| 253 | task.setOnFailed( e -> { | |
| 254 | final var ex = task.getException(); | |
| 255 | clue( ex ); | |
| 256 | ||
| 257 | if( ex instanceof TypeNotPresentException ) { | |
| 258 | fireExportFailedEvent(); | |
| 259 | } | |
| 260 | } ); | |
| 261 | ||
| 262 | return task; | |
| 263 | } | |
| 264 | }; | |
| 265 | ||
| 266 | mTypesetService = service; | |
| 267 | typeset( service ); | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * @param dir {@code true} means to export all files in the active file | |
| 272 | * editor's directory; {@code false} means to export only the | |
| 273 | * actively edited file. | |
| 274 | */ | |
| 275 | private void file_export_pdf( final boolean dir ) { | |
| 276 | // Don't re-validate the typesetter installation each time. If the | |
| 277 | // user mucks up the typesetter installation, it'll get caught the | |
| 278 | // next time the application is started. Don't use |= because it | |
| 279 | // won't short-circuit. | |
| 280 | mCanTypeset = mCanTypeset || Typesetter.canRun(); | |
| 281 | ||
| 282 | if( mCanTypeset ) { | |
| 283 | final var workspace = getWorkspace(); | |
| 284 | final var theme = workspace.stringProperty( | |
| 285 | KEY_TYPESET_CONTEXT_THEME_SELECTION | |
| 286 | ); | |
| 287 | final var chapters = workspace.stringProperty( | |
| 288 | KEY_TYPESET_CONTEXT_CHAPTERS | |
| 289 | ); | |
| 290 | ||
| 291 | final var settings = ExportSettings | |
| 292 | .builder() | |
| 293 | .with( ExportSettings.Mutator::setTheme, theme ) | |
| 294 | .with( ExportSettings.Mutator::setChapters, chapters ) | |
| 295 | .build(); | |
| 296 | ||
| 297 | final var themes = workspace.getFile( | |
| 298 | KEY_TYPESET_CONTEXT_THEMES_PATH | |
| 299 | ); | |
| 300 | ||
| 301 | // If the typesetter is installed, allow the user to select a theme. If | |
| 302 | // the themes aren't installed, a status message will appear. | |
| 303 | if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) { | |
| 304 | file_export( APPLICATION_PDF, dir ); | |
| 305 | } | |
| 306 | } | |
| 307 | else { | |
| 308 | fireExportFailedEvent(); | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | public void file_export_pdf() { | |
| 313 | file_export_pdf( false ); | |
| 314 | } | |
| 315 | ||
| 316 | public void file_export_pdf_dir() { | |
| 317 | file_export_pdf( true ); | |
| 318 | } | |
| 319 | ||
| 320 | public void file_export_html_dir() { | |
| 321 | file_export( XHTML_TEX, true ); | |
| 322 | } | |
| 323 | ||
| 324 | public void file_export_repeat() { | |
| 325 | typeset( mTypesetService ); | |
| 326 | } | |
| 327 | ||
| 328 | public void file_export_html_svg() { | |
| 329 | file_export( HTML_TEX_SVG ); | |
| 330 | } | |
| 331 | ||
| 332 | public void file_export_html_tex() { | |
| 333 | file_export( HTML_TEX_DELIMITED ); | |
| 334 | } | |
| 335 | ||
| 336 | public void file_export_xhtml_tex() { | |
| 337 | file_export( XHTML_TEX ); | |
| 338 | } | |
| 339 | ||
| 340 | private void fireExportFailedEvent() { | |
| 341 | runLater( ExportFailedEvent::fire ); | |
| 342 | } | |
| 343 | ||
| 344 | public void file_exit() { | |
| 345 | final var window = getWindow(); | |
| 346 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 347 | } | |
| 348 | ||
| 349 | public void edit_undo() { | |
| 350 | getActiveTextEditor().undo(); | |
| 351 | } | |
| 352 | ||
| 353 | public void edit_redo() { | |
| 354 | getActiveTextEditor().redo(); | |
| 355 | } | |
| 356 | ||
| 357 | public void edit_cut() { | |
| 358 | getActiveTextEditor().cut(); | |
| 359 | } | |
| 360 | ||
| 361 | public void edit_copy() { | |
| 362 | getActiveTextEditor().copy(); | |
| 363 | } | |
| 364 | ||
| 365 | public void edit_paste() { | |
| 366 | getActiveTextEditor().paste(); | |
| 367 | } | |
| 368 | ||
| 369 | public void edit_select_all() { | |
| 370 | getActiveTextEditor().selectAll(); | |
| 371 | } | |
| 372 | ||
| 373 | public void edit_find() { | |
| 374 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 375 | ||
| 376 | if( nodes.isEmpty() ) { | |
| 377 | final var searchBar = new SearchBar(); | |
| 378 | ||
| 379 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 380 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 381 | ||
| 382 | searchBar.setOnCancelAction( event -> { | |
| 383 | final var editor = getActiveTextEditor(); | |
| 384 | nodes.remove( searchBar ); | |
| 385 | editor.unstylize( STYLE_SEARCH ); | |
| 386 | editor.getNode().requestFocus(); | |
| 387 | } ); | |
| 388 | ||
| 389 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 390 | if( n != null && !n.isEmpty() ) { | |
| 391 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 392 | } | |
| 393 | } ); | |
| 394 | ||
| 395 | searchBar.setOnNextAction( event -> edit_find_next() ); | |
| 396 | searchBar.setOnPrevAction( event -> edit_find_prev() ); | |
| 397 | ||
| 398 | nodes.add( searchBar ); | |
| 399 | searchBar.requestFocus(); | |
| 400 | } | |
| 401 | } | |
| 402 | ||
| 403 | public void edit_find_next() { | |
| 404 | mSearchModel.advance(); | |
| 405 | } | |
| 406 | ||
| 407 | public void edit_find_prev() { | |
| 408 | mSearchModel.retreat(); | |
| 409 | } | |
| 410 | ||
| 411 | public void edit_preferences() { | |
| 412 | try { | |
| 413 | new PreferencesController( getWorkspace() ).show(); | |
| 414 | } catch( final Exception ex ) { | |
| 415 | clue( ex ); | |
| 416 | } | |
| 417 | } | |
| 418 | ||
| 419 | public void format_bold() { | |
| 420 | getActiveTextEditor().bold(); | |
| 421 | } | |
| 422 | ||
| 423 | public void format_italic() { | |
| 424 | getActiveTextEditor().italic(); | |
| 425 | } | |
| 426 | ||
| 427 | public void format_monospace() { | |
| 428 | getActiveTextEditor().monospace(); | |
| 429 | } | |
| 430 | ||
| 431 | public void format_superscript() { | |
| 432 | getActiveTextEditor().superscript(); | |
| 433 | } | |
| 434 | ||
| 435 | public void format_subscript() { | |
| 436 | getActiveTextEditor().subscript(); | |
| 437 | } | |
| 438 | ||
| 439 | public void format_strikethrough() { | |
| 440 | getActiveTextEditor().strikethrough(); | |
| 441 | } | |
| 442 | ||
| 443 | public void insert_blockquote() { | |
| 444 | getActiveTextEditor().blockquote(); | |
| 445 | } | |
| 446 | ||
| 447 | public void insert_code() { | |
| 448 | getActiveTextEditor().code(); | |
| 449 | } | |
| 450 | ||
| 451 | public void insert_fenced_code_block() { | |
| 452 | getActiveTextEditor().fencedCodeBlock(); | |
| 453 | } | |
| 454 | ||
| 455 | public void insert_link() { | |
| 456 | insertObject( createLinkDialog() ); | |
| 457 | } | |
| 458 | ||
| 459 | public void insert_image() { | |
| 460 | insertObject( createImageDialog() ); | |
| 461 | } | |
| 462 | ||
| 463 | private void insertObject( final Dialog<String> dialog ) { | |
| 464 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 465 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 466 | } | |
| 467 | ||
| 468 | private Dialog<String> createLinkDialog() { | |
| 469 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 470 | } | |
| 471 | ||
| 472 | private Dialog<String> createImageDialog() { | |
| 473 | final var path = getActiveTextEditor().getPath(); | |
| 474 | final var parentDir = path.getParent(); | |
| 475 | return new ImageDialog( getWindow(), parentDir ); | |
| 476 | } | |
| 477 | ||
| 478 | /** | |
| 479 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 480 | * the Markdown AST. | |
| 481 | * | |
| 482 | * @return An instance containing the link URL and display text. | |
| 483 | */ | |
| 484 | private HyperlinkModel createHyperlinkModel() { | |
| 485 | final var context = getMainPane().createProcessorContext(); | |
| 486 | final var editor = getActiveTextEditor(); | |
| 487 | final var textArea = editor.getTextArea(); | |
| 488 | final var selectedText = textArea.getSelectedText(); | |
| 489 | ||
| 490 | // Convert current paragraph to Markdown nodes. | |
| 491 | final var mp = MarkdownProcessor.create( context ); | |
| 492 | final var p = textArea.getCurrentParagraph(); | |
| 493 | final var paragraph = textArea.getText( p ); | |
| 494 | final var node = mp.toNode( paragraph ); | |
| 495 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 496 | final var link = visitor.process( node ); | |
| 497 | ||
| 498 | if( link != null ) { | |
| 499 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 500 | } | |
| 501 | ||
| 502 | return createHyperlinkModel( link, selectedText ); | |
| 503 | } | |
| 504 | ||
| 505 | private HyperlinkModel createHyperlinkModel( | |
| 506 | final Link link, final String selection ) { | |
| 507 | ||
| 508 | return link == null | |
| 509 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 510 | : new HyperlinkModel( link ); | |
| 511 | } | |
| 512 | ||
| 513 | public void insert_heading_1() { | |
| 514 | insert_heading( 1 ); | |
| 515 | } | |
| 516 | ||
| 517 | public void insert_heading_2() { | |
| 518 | insert_heading( 2 ); | |
| 519 | } | |
| 520 | ||
| 521 | public void insert_heading_3() { | |
| 522 | insert_heading( 3 ); | |
| 523 | } | |
| 524 | ||
| 525 | private void insert_heading( final int level ) { | |
| 526 | getActiveTextEditor().heading( level ); | |
| 527 | } | |
| 528 | ||
| 529 | public void insert_unordered_list() { | |
| 530 | getActiveTextEditor().unorderedList(); | |
| 531 | } | |
| 532 | ||
| 533 | public void insert_ordered_list() { | |
| 534 | getActiveTextEditor().orderedList(); | |
| 535 | } | |
| 536 | ||
| 537 | public void insert_horizontal_rule() { | |
| 538 | getActiveTextEditor().horizontalRule(); | |
| 539 | } | |
| 540 | ||
| 541 | public void definition_create() { | |
| 542 | getActiveTextDefinition().createDefinition(); | |
| 543 | } | |
| 544 | ||
| 545 | public void definition_rename() { | |
| 546 | getActiveTextDefinition().renameDefinition(); | |
| 547 | } | |
| 548 | ||
| 549 | public void definition_delete() { | |
| 550 | getActiveTextDefinition().deleteDefinitions(); | |
| 551 | } | |
| 552 | ||
| 553 | public void definition_autoinsert() { | |
| 554 | getMainPane().autoinsert(); | |
| 555 | } | |
| 556 | ||
| 557 | public void view_refresh() { | |
| 558 | getMainPane().viewRefresh(); | |
| 559 | } | |
| 560 | ||
| 561 | public void view_preview() { | |
| 562 | getMainPane().viewPreview(); | |
| 563 | } | |
| 564 | ||
| 565 | public void view_outline() { | |
| 566 | getMainPane().viewOutline(); | |
| 567 | } | |
| 568 | ||
| 569 | public void view_files() { getMainPane().viewFiles(); } | |
| 570 | ||
| 571 | public void view_statistics() { | |
| 572 | getMainPane().viewStatistics(); | |
| 573 | } | |
| 574 | ||
| 575 | public void view_menubar() { | |
| 576 | getMainScene().toggleMenuBar(); | |
| 577 | } | |
| 578 | ||
| 579 | public void view_toolbar() { | |
| 580 | getMainScene().toggleToolBar(); | |
| 581 | } | |
| 582 | ||
| 583 | public void view_statusbar() { | |
| 584 | getMainScene().toggleStatusBar(); | |
| 585 | } | |
| 586 | ||
| 587 | public void view_log() { | |
| 588 | mLogView.view(); | |
| 589 | } | |
| 590 | ||
| 591 | public void help_about() { | |
| 592 | final var alert = new Alert( INFORMATION ); | |
| 593 | final var prefix = "Dialog.about."; | |
| 594 | alert.setTitle( get( prefix + "title", APP_TITLE ) ); | |
| 595 | alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | |
| 596 | alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | |
| 597 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 598 | alert.initOwner( getWindow() ); | |
| 599 | alert.showAndWait(); | |
| 600 | } | |
| 601 | ||
| 602 | private <T> void typeset( final Service<T> service ) { | |
| 603 | synchronized( mMutex ) { | |
| 604 | if( service != null && !service.isRunning() ) { | |
| 605 | service.reset(); | |
| 606 | service.start(); | |
| 607 | } | |
| 608 | } | |
| 609 | } | |
| 610 | ||
| 611 | /** | |
| 612 | * Concatenates all the files in the same directory as the given file into | |
| 613 | * a string. The extension is determined by the given file name pattern; the | |
| 614 | * order files are concatenated is based on their numeric sort order (this | |
| 615 | * avoids lexicographic sorting). | |
| 616 | * <p> | |
| 617 | * If the parent path to the file being edited in the text editor cannot | |
| 618 | * be found then this will return the editor's text, without iterating through | |
| 619 | * the parent directory. (Should never happen, but who knows?) | |
| 620 | * </p> | |
| 621 | * <p> | |
| 622 | * New lines are automatically appended to separate each file. | |
| 623 | * </p> | |
| 624 | * | |
| 625 | * @param editor The text editor containing | |
| 626 | * @return All files in the same directory as the file being edited | |
| 627 | * concatenated into a single string. | |
| 628 | */ | |
| 629 | private String append( final TextEditor editor ) { | |
| 630 | final var pattern = editor.getPath(); | |
| 631 | final var parent = pattern.getParent(); | |
| 632 | ||
| 633 | // Short-circuit because nothing else can be done. | |
| 634 | if( parent == null ) { | |
| 635 | clue( "Main.status.export.concat.parent", pattern ); | |
| 636 | return editor.getText(); | |
| 637 | } | |
| 638 | ||
| 639 | final var filename = SysFile.getFileName( pattern ); | |
| 640 | final var extension = getExtension( filename ); | |
| 641 | ||
| 642 | if( extension.isBlank() ) { | |
| 643 | clue( "Main.status.export.concat.extension", filename ); | |
| 644 | return editor.getText(); | |
| 645 | } | |
| 646 | ||
| 647 | try { | |
| 648 | final var command = new ConcatenateCommand( | |
| 649 | parent, extension, getString( KEY_TYPESET_CONTEXT_CHAPTERS ) ); | |
| 650 | return command.call(); | |
| 651 | } catch( final Throwable t ) { | |
| 652 | clue( t ); | |
| 653 | return editor.getText(); | |
| 654 | } | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.actions; | |
| 6 | ||
| 7 | import com.keenwrite.ExportFormat; | |
| 8 | import com.keenwrite.MainPane; | |
| 9 | import com.keenwrite.MainScene; | |
| 10 | import com.keenwrite.commands.ConcatenateCommand; | |
| 11 | import com.keenwrite.editors.TextDefinition; | |
| 12 | import com.keenwrite.editors.TextEditor; | |
| 13 | import com.keenwrite.editors.markdown.LinkVisitor; | |
| 14 | import com.keenwrite.events.CaretMovedEvent; | |
| 15 | import com.keenwrite.events.ExportFailedEvent; | |
| 16 | import com.keenwrite.io.SysFile; | |
| 17 | import com.keenwrite.preferences.Key; | |
| 18 | import com.keenwrite.preferences.PreferencesController; | |
| 19 | import com.keenwrite.preferences.Workspace; | |
| 20 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 21 | import com.keenwrite.search.SearchModel; | |
| 22 | import com.keenwrite.typesetting.Typesetter; | |
| 23 | import com.keenwrite.ui.controls.SearchBar; | |
| 24 | import com.keenwrite.ui.dialogs.*; | |
| 25 | import com.keenwrite.ui.explorer.FilePicker; | |
| 26 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 27 | import com.keenwrite.ui.logging.LogView; | |
| 28 | import com.keenwrite.ui.models.HyperlinkModel; | |
| 29 | import com.keenwrite.ui.models.ImageModel; | |
| 30 | import com.vladsch.flexmark.ast.Link; | |
| 31 | import javafx.concurrent.Service; | |
| 32 | import javafx.concurrent.Task; | |
| 33 | import javafx.scene.control.Alert; | |
| 34 | import javafx.scene.control.Dialog; | |
| 35 | import javafx.stage.Window; | |
| 36 | import javafx.stage.WindowEvent; | |
| 37 | ||
| 38 | import java.io.File; | |
| 39 | import java.nio.file.Path; | |
| 40 | import java.util.List; | |
| 41 | import java.util.Optional; | |
| 42 | ||
| 43 | import static com.keenwrite.Bootstrap.*; | |
| 44 | import static com.keenwrite.ExportFormat.*; | |
| 45 | import static com.keenwrite.Messages.get; | |
| 46 | import static com.keenwrite.constants.Constants.PDF_DEFAULT; | |
| 47 | import static com.keenwrite.constants.Constants.USER_DIRECTORY; | |
| 48 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 49 | import static com.keenwrite.events.StatusEvent.clue; | |
| 50 | import static com.keenwrite.preferences.AppKeys.*; | |
| 51 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 52 | import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType; | |
| 53 | import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*; | |
| 54 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 55 | import static java.nio.file.Files.writeString; | |
| 56 | import static javafx.application.Platform.runLater; | |
| 57 | import static javafx.event.Event.fireEvent; | |
| 58 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 59 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 60 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 61 | ||
| 62 | /** | |
| 63 | * Responsible for abstracting how functionality is mapped to the application. | |
| 64 | * This allows users to customize accelerator keys and will provide pluggable | |
| 65 | * functionality so that different text markup languages can change documents | |
| 66 | * using their respective syntax. | |
| 67 | */ | |
| 68 | public final class GuiCommands { | |
| 69 | private static final String STYLE_SEARCH = "search"; | |
| 70 | ||
| 71 | /** | |
| 72 | * When an action is executed, this is one of the recipients. | |
| 73 | */ | |
| 74 | private final MainPane mMainPane; | |
| 75 | ||
| 76 | private final MainScene mMainScene; | |
| 77 | ||
| 78 | private final LogView mLogView; | |
| 79 | ||
| 80 | /** | |
| 81 | * Tracks finding text in the active document. | |
| 82 | */ | |
| 83 | private final SearchModel mSearchModel; | |
| 84 | ||
| 85 | private boolean mCanTypeset; | |
| 86 | ||
| 87 | /** | |
| 88 | * A {@link Task} can only be run once, so wrap it in a {@link Service} to | |
| 89 | * allow re-launching the typesetting task repeatedly. | |
| 90 | */ | |
| 91 | private Service<Path> mTypesetService; | |
| 92 | ||
| 93 | /** | |
| 94 | * Prevent a race-condition between checking to see if the typesetting task | |
| 95 | * is running and restarting the task itself. | |
| 96 | */ | |
| 97 | private final Object mMutex = new Object(); | |
| 98 | ||
| 99 | public GuiCommands( final MainScene scene, final MainPane pane ) { | |
| 100 | mMainScene = scene; | |
| 101 | mMainPane = pane; | |
| 102 | mLogView = new LogView(); | |
| 103 | mSearchModel = new SearchModel(); | |
| 104 | mSearchModel.matchOffsetProperty().addListener( ( _, o, n ) -> { | |
| 105 | final var editor = getActiveTextEditor(); | |
| 106 | ||
| 107 | // Clear highlighted areas before highlighting a new region. | |
| 108 | if( o != null ) { | |
| 109 | editor.unstylize( STYLE_SEARCH ); | |
| 110 | } | |
| 111 | ||
| 112 | if( n != null ) { | |
| 113 | editor.moveTo( n.getStart() ); | |
| 114 | editor.stylize( n, STYLE_SEARCH ); | |
| 115 | } | |
| 116 | } ); | |
| 117 | ||
| 118 | // When the active text editor changes ... | |
| 119 | mMainPane.textEditorProperty().addListener( | |
| 120 | ( _, _, n ) -> { | |
| 121 | // ... update the haystack. | |
| 122 | mSearchModel.search( getActiveTextEditor().getText() ); | |
| 123 | ||
| 124 | // ... update the status bar with the current caret position. | |
| 125 | if( n != null ) { | |
| 126 | final var w = getWorkspace(); | |
| 127 | final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT ); | |
| 128 | ||
| 129 | // ... preserve the most recent document. | |
| 130 | recentDoc.setValue( n.getFile() ); | |
| 131 | CaretMovedEvent.fire( n.getCaret() ); | |
| 132 | } | |
| 133 | } | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | public void file_new() { | |
| 138 | getMainPane().newTextEditor(); | |
| 139 | } | |
| 140 | ||
| 141 | public void file_open() { | |
| 142 | pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | |
| 143 | } | |
| 144 | ||
| 145 | public void file_open_url() { | |
| 146 | pickFile().ifPresent( l -> getMainPane().open( List.of( l ) ) ); | |
| 147 | } | |
| 148 | ||
| 149 | public void file_close() { | |
| 150 | getMainPane().close(); | |
| 151 | } | |
| 152 | ||
| 153 | public void file_close_all() { | |
| 154 | getMainPane().closeAll(); | |
| 155 | } | |
| 156 | ||
| 157 | public void file_save() { | |
| 158 | getMainPane().save(); | |
| 159 | } | |
| 160 | ||
| 161 | public void file_save_as() { | |
| 162 | pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | |
| 163 | } | |
| 164 | ||
| 165 | public void file_save_all() { | |
| 166 | getMainPane().saveAll(); | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Converts the actively edited file in the given file format. | |
| 171 | * | |
| 172 | * @param format The destination file format. | |
| 173 | */ | |
| 174 | private void file_export( final ExportFormat format ) { | |
| 175 | file_export( format, false ); | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * Converts one or more files into the given file format. If {@code dir} | |
| 180 | * is set to true, this will first append all files in the same directory | |
| 181 | * as the actively edited file. | |
| 182 | * | |
| 183 | * @param format The destination file format. | |
| 184 | * @param dir Export all files in the actively edited file's directory. | |
| 185 | */ | |
| 186 | private void file_export( final ExportFormat format, final boolean dir ) { | |
| 187 | final var editor = getMainPane().getTextEditor(); | |
| 188 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 189 | final var exportParent = exported.get().toPath().getParent(); | |
| 190 | final var editorParent = editor.getPath().getParent(); | |
| 191 | final var userHomeParent = USER_DIRECTORY.toPath(); | |
| 192 | final var exportPath = exportParent != null | |
| 193 | ? exportParent | |
| 194 | : editorParent != null | |
| 195 | ? editorParent | |
| 196 | : userHomeParent; | |
| 197 | ||
| 198 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 199 | final var selected = PDF_DEFAULT | |
| 200 | .getName() | |
| 201 | .equals( exported.get().getName() ); | |
| 202 | final var selection = pickFile( | |
| 203 | selected | |
| 204 | ? filename | |
| 205 | : exported.get(), | |
| 206 | exportPath, | |
| 207 | FILE_EXPORT | |
| 208 | ); | |
| 209 | ||
| 210 | selection.ifPresent( files -> file_export( editor, format, files, dir ) ); | |
| 211 | } | |
| 212 | ||
| 213 | private void file_export( | |
| 214 | final TextEditor editor, | |
| 215 | final ExportFormat format, | |
| 216 | final List<File> files, | |
| 217 | final boolean dir ) { | |
| 218 | editor.save(); | |
| 219 | final var main = getMainPane(); | |
| 220 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 221 | ||
| 222 | final var sourceFile = files.getFirst(); | |
| 223 | final var sourcePath = sourceFile.toPath(); | |
| 224 | final var document = dir ? append( editor ) : editor.getText(); | |
| 225 | final var context = main.createProcessorContext( sourcePath, format ); | |
| 226 | ||
| 227 | final var service = new Service<Path>() { | |
| 228 | @Override | |
| 229 | protected Task<Path> createTask() { | |
| 230 | final var task = new Task<Path>() { | |
| 231 | @Override | |
| 232 | protected Path call() throws Exception { | |
| 233 | final var chain = createProcessors( context ); | |
| 234 | final var export = chain.apply( document ); | |
| 235 | ||
| 236 | // Processors can export binary files. In such cases, processors | |
| 237 | // return null to prevent further processing. | |
| 238 | return export == null | |
| 239 | ? null | |
| 240 | : writeString( sourcePath, export, UTF_8 ); | |
| 241 | } | |
| 242 | }; | |
| 243 | ||
| 244 | task.setOnSucceeded( | |
| 245 | _ -> { | |
| 246 | // Remember the exported file name for next time. | |
| 247 | exported.setValue( sourceFile ); | |
| 248 | ||
| 249 | final var result = task.getValue(); | |
| 250 | ||
| 251 | // Binary formats must notify users of success independently. | |
| 252 | if( result != null ) { | |
| 253 | clue( "Main.status.export.success", result ); | |
| 254 | } | |
| 255 | } | |
| 256 | ); | |
| 257 | ||
| 258 | task.setOnFailed( _ -> { | |
| 259 | final var ex = task.getException(); | |
| 260 | clue( ex ); | |
| 261 | ||
| 262 | if( ex instanceof TypeNotPresentException ) { | |
| 263 | fireExportFailedEvent(); | |
| 264 | } | |
| 265 | } ); | |
| 266 | ||
| 267 | return task; | |
| 268 | } | |
| 269 | }; | |
| 270 | ||
| 271 | mTypesetService = service; | |
| 272 | typeset( service ); | |
| 273 | } | |
| 274 | ||
| 275 | /** | |
| 276 | * @param dir {@code true} means to export all files in the active file | |
| 277 | * editor's directory; {@code false} means to export only the | |
| 278 | * actively edited file. | |
| 279 | */ | |
| 280 | private void file_export_pdf( final boolean dir ) { | |
| 281 | // Don't re-validate the typesetter installation each time. If the | |
| 282 | // user mucks up the typesetter installation, it'll get caught the | |
| 283 | // next time the application is started. Don't use |= because it | |
| 284 | // won't short-circuit. | |
| 285 | mCanTypeset = mCanTypeset || Typesetter.canRun(); | |
| 286 | ||
| 287 | if( mCanTypeset ) { | |
| 288 | final var workspace = getWorkspace(); | |
| 289 | final var theme = workspace.stringProperty( | |
| 290 | KEY_TYPESET_CONTEXT_THEME_SELECTION | |
| 291 | ); | |
| 292 | final var chapters = workspace.stringProperty( | |
| 293 | KEY_TYPESET_CONTEXT_CHAPTERS | |
| 294 | ); | |
| 295 | ||
| 296 | final var settings = ExportSettings | |
| 297 | .builder() | |
| 298 | .with( ExportSettings.Mutator::setTheme, theme ) | |
| 299 | .with( ExportSettings.Mutator::setChapters, chapters ) | |
| 300 | .build(); | |
| 301 | ||
| 302 | final var themes = workspace.getFile( | |
| 303 | KEY_TYPESET_CONTEXT_THEMES_PATH | |
| 304 | ); | |
| 305 | ||
| 306 | // If the typesetter is installed, allow the user to select a theme. If | |
| 307 | // the themes aren't installed, a status message will appear. | |
| 308 | if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) { | |
| 309 | file_export( APPLICATION_PDF, dir ); | |
| 310 | } | |
| 311 | } | |
| 312 | else { | |
| 313 | fireExportFailedEvent(); | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | public void file_export_pdf() { | |
| 318 | file_export_pdf( false ); | |
| 319 | } | |
| 320 | ||
| 321 | public void file_export_pdf_dir() { | |
| 322 | file_export_pdf( true ); | |
| 323 | } | |
| 324 | ||
| 325 | public void file_export_html_dir() { | |
| 326 | file_export( XHTML_TEX, true ); | |
| 327 | } | |
| 328 | ||
| 329 | public void file_export_repeat() { | |
| 330 | typeset( mTypesetService ); | |
| 331 | } | |
| 332 | ||
| 333 | public void file_export_html_svg() { | |
| 334 | file_export( HTML_TEX_SVG ); | |
| 335 | } | |
| 336 | ||
| 337 | public void file_export_html_tex() { | |
| 338 | file_export( HTML_TEX_DELIMITED ); | |
| 339 | } | |
| 340 | ||
| 341 | public void file_export_xhtml_tex() { | |
| 342 | file_export( XHTML_TEX ); | |
| 343 | } | |
| 344 | ||
| 345 | private void fireExportFailedEvent() { | |
| 346 | runLater( ExportFailedEvent::fire ); | |
| 347 | } | |
| 348 | ||
| 349 | public void file_exit() { | |
| 350 | final var window = getWindow(); | |
| 351 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 352 | } | |
| 353 | ||
| 354 | public void edit_undo() { | |
| 355 | getActiveTextEditor().undo(); | |
| 356 | } | |
| 357 | ||
| 358 | public void edit_redo() { | |
| 359 | getActiveTextEditor().redo(); | |
| 360 | } | |
| 361 | ||
| 362 | public void edit_cut() { | |
| 363 | getActiveTextEditor().cut(); | |
| 364 | } | |
| 365 | ||
| 366 | public void edit_copy() { | |
| 367 | getActiveTextEditor().copy(); | |
| 368 | } | |
| 369 | ||
| 370 | public void edit_paste() { | |
| 371 | getActiveTextEditor().paste(); | |
| 372 | } | |
| 373 | ||
| 374 | public void edit_select_all() { | |
| 375 | getActiveTextEditor().selectAll(); | |
| 376 | } | |
| 377 | ||
| 378 | public void edit_find() { | |
| 379 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 380 | ||
| 381 | if( nodes.isEmpty() ) { | |
| 382 | final var searchBar = new SearchBar(); | |
| 383 | ||
| 384 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 385 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 386 | ||
| 387 | searchBar.setOnCancelAction( _ -> { | |
| 388 | final var editor = getActiveTextEditor(); | |
| 389 | nodes.remove( searchBar ); | |
| 390 | editor.unstylize( STYLE_SEARCH ); | |
| 391 | editor.getNode().requestFocus(); | |
| 392 | } ); | |
| 393 | ||
| 394 | searchBar.addInputListener( ( _, _, n ) -> { | |
| 395 | if( n != null && !n.isEmpty() ) { | |
| 396 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 397 | } | |
| 398 | } ); | |
| 399 | ||
| 400 | searchBar.setOnNextAction( _ -> edit_find_next() ); | |
| 401 | searchBar.setOnPrevAction( _ -> edit_find_prev() ); | |
| 402 | ||
| 403 | nodes.add( searchBar ); | |
| 404 | searchBar.requestFocus(); | |
| 405 | } | |
| 406 | } | |
| 407 | ||
| 408 | public void edit_find_next() { | |
| 409 | mSearchModel.advance(); | |
| 410 | } | |
| 411 | ||
| 412 | public void edit_find_prev() { | |
| 413 | mSearchModel.retreat(); | |
| 414 | } | |
| 415 | ||
| 416 | public void edit_preferences() { | |
| 417 | try { | |
| 418 | new PreferencesController( getWorkspace() ).show(); | |
| 419 | } catch( final Exception ex ) { | |
| 420 | clue( ex ); | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | public void format_bold() { | |
| 425 | getActiveTextEditor().bold(); | |
| 426 | } | |
| 427 | ||
| 428 | public void format_italic() { | |
| 429 | getActiveTextEditor().italic(); | |
| 430 | } | |
| 431 | ||
| 432 | public void format_monospace() { | |
| 433 | getActiveTextEditor().monospace(); | |
| 434 | } | |
| 435 | ||
| 436 | public void format_superscript() { | |
| 437 | getActiveTextEditor().superscript(); | |
| 438 | } | |
| 439 | ||
| 440 | public void format_subscript() { | |
| 441 | getActiveTextEditor().subscript(); | |
| 442 | } | |
| 443 | ||
| 444 | public void format_strikethrough() { | |
| 445 | getActiveTextEditor().strikethrough(); | |
| 446 | } | |
| 447 | ||
| 448 | public void insert_blockquote() { | |
| 449 | getActiveTextEditor().blockquote(); | |
| 450 | } | |
| 451 | ||
| 452 | public void insert_code() { | |
| 453 | getActiveTextEditor().code(); | |
| 454 | } | |
| 455 | ||
| 456 | public void insert_fenced_code_block() { | |
| 457 | getActiveTextEditor().fencedCodeBlock(); | |
| 458 | } | |
| 459 | ||
| 460 | public void insert_link() { | |
| 461 | insertObject( createLinkDialog() ); | |
| 462 | } | |
| 463 | ||
| 464 | public void insert_image() { | |
| 465 | insertObject( createImageDialog() ); | |
| 466 | } | |
| 467 | ||
| 468 | private void insertObject( final Dialog<String> dialog ) { | |
| 469 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 470 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 471 | } | |
| 472 | ||
| 473 | private Dialog<String> createLinkDialog() { | |
| 474 | return new HyperlinkDialog( getWindow(), createHyperlinkModel() ); | |
| 475 | } | |
| 476 | ||
| 477 | private Dialog<String> createImageDialog() { | |
| 478 | return new ImageDialog( getWindow(), createImageModel() ); | |
| 479 | } | |
| 480 | ||
| 481 | /** | |
| 482 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 483 | * the Markdown AST. When a user opts to insert a hyperlink, this will | |
| 484 | * populate the insert hyperlink dialog with data from the document, thereby | |
| 485 | * allowing a user to edit an existing link. | |
| 486 | * | |
| 487 | * @return An instance containing the link URL and display text. | |
| 488 | */ | |
| 489 | private HyperlinkModel createHyperlinkModel() { | |
| 490 | final var context = getMainPane().createProcessorContext(); | |
| 491 | final var editor = getActiveTextEditor(); | |
| 492 | final var textArea = editor.getTextArea(); | |
| 493 | final var selectedText = textArea.getSelectedText(); | |
| 494 | ||
| 495 | // Convert current paragraph to Markdown nodes. | |
| 496 | final var mp = MarkdownProcessor.create( context ); | |
| 497 | final var p = textArea.getCurrentParagraph(); | |
| 498 | final var paragraph = textArea.getText( p ); | |
| 499 | final var node = mp.toNode( paragraph ); | |
| 500 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 501 | final var link = visitor.process( node ); | |
| 502 | ||
| 503 | if( link != null ) { | |
| 504 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 505 | } | |
| 506 | ||
| 507 | return createHyperlinkModel( link, selectedText ); | |
| 508 | } | |
| 509 | ||
| 510 | private HyperlinkModel createHyperlinkModel( | |
| 511 | final Link link, final String selection ) { | |
| 512 | ||
| 513 | return link == null | |
| 514 | ? new HyperlinkModel( selection ) | |
| 515 | : new HyperlinkModel( link ); | |
| 516 | } | |
| 517 | ||
| 518 | private ImageModel createImageModel() { | |
| 519 | return new ImageModel( "" ); | |
| 520 | } | |
| 521 | ||
| 522 | public void insert_heading_1() { | |
| 523 | insert_heading( 1 ); | |
| 524 | } | |
| 525 | ||
| 526 | public void insert_heading_2() { | |
| 527 | insert_heading( 2 ); | |
| 528 | } | |
| 529 | ||
| 530 | public void insert_heading_3() { | |
| 531 | insert_heading( 3 ); | |
| 532 | } | |
| 533 | ||
| 534 | private void insert_heading( final int level ) { | |
| 535 | getActiveTextEditor().heading( level ); | |
| 536 | } | |
| 537 | ||
| 538 | public void insert_unordered_list() { | |
| 539 | getActiveTextEditor().unorderedList(); | |
| 540 | } | |
| 541 | ||
| 542 | public void insert_ordered_list() { | |
| 543 | getActiveTextEditor().orderedList(); | |
| 544 | } | |
| 545 | ||
| 546 | public void insert_horizontal_rule() { | |
| 547 | getActiveTextEditor().horizontalRule(); | |
| 548 | } | |
| 549 | ||
| 550 | public void definition_create() { | |
| 551 | getActiveTextDefinition().createDefinition(); | |
| 552 | } | |
| 553 | ||
| 554 | public void definition_rename() { | |
| 555 | getActiveTextDefinition().renameDefinition(); | |
| 556 | } | |
| 557 | ||
| 558 | public void definition_delete() { | |
| 559 | getActiveTextDefinition().deleteDefinitions(); | |
| 560 | } | |
| 561 | ||
| 562 | public void definition_autoinsert() { | |
| 563 | getMainPane().autoinsert(); | |
| 564 | } | |
| 565 | ||
| 566 | public void view_refresh() { | |
| 567 | getMainPane().viewRefresh(); | |
| 568 | } | |
| 569 | ||
| 570 | public void view_preview() { | |
| 571 | getMainPane().viewPreview(); | |
| 572 | } | |
| 573 | ||
| 574 | public void view_outline() { | |
| 575 | getMainPane().viewOutline(); | |
| 576 | } | |
| 577 | ||
| 578 | public void view_files() {getMainPane().viewFiles();} | |
| 579 | ||
| 580 | public void view_statistics() { | |
| 581 | getMainPane().viewStatistics(); | |
| 582 | } | |
| 583 | ||
| 584 | public void view_menubar() { | |
| 585 | getMainScene().toggleMenuBar(); | |
| 586 | } | |
| 587 | ||
| 588 | public void view_toolbar() { | |
| 589 | getMainScene().toggleToolBar(); | |
| 590 | } | |
| 591 | ||
| 592 | public void view_statusbar() { | |
| 593 | getMainScene().toggleStatusBar(); | |
| 594 | } | |
| 595 | ||
| 596 | public void view_log() { | |
| 597 | mLogView.view(); | |
| 598 | } | |
| 599 | ||
| 600 | public void help_about() { | |
| 601 | final var alert = new Alert( INFORMATION ); | |
| 602 | final var prefix = "Dialog.about."; | |
| 603 | alert.setTitle( get( STR."\{prefix}title", APP_TITLE ) ); | |
| 604 | alert.setHeaderText( get( STR."\{prefix}header", APP_TITLE ) ); | |
| 605 | alert.setContentText( get( STR."\{prefix}content", | |
| 606 | APP_YEAR, | |
| 607 | APP_VERSION ) ); | |
| 608 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 609 | alert.initOwner( getWindow() ); | |
| 610 | alert.showAndWait(); | |
| 611 | } | |
| 612 | ||
| 613 | private <T> void typeset( final Service<T> service ) { | |
| 614 | synchronized( mMutex ) { | |
| 615 | if( service != null && !service.isRunning() ) { | |
| 616 | service.reset(); | |
| 617 | service.start(); | |
| 618 | } | |
| 619 | } | |
| 620 | } | |
| 621 | ||
| 622 | /** | |
| 623 | * Concatenates all the files in the same directory as the given file into | |
| 624 | * a string. The extension is determined by the given file name pattern; the | |
| 625 | * order files are concatenated is based on their numeric sort order (this | |
| 626 | * avoids lexicographic sorting). | |
| 627 | * <p> | |
| 628 | * If the parent path to the file being edited in the text editor cannot | |
| 629 | * be found then this will return the editor's text, without iterating through | |
| 630 | * the parent directory. (Should never happen, but who knows?) | |
| 631 | * </p> | |
| 632 | * <p> | |
| 633 | * New lines are automatically appended to separate each file. | |
| 634 | * </p> | |
| 635 | * | |
| 636 | * @param editor The text editor containing | |
| 637 | * @return All files in the same directory as the file being edited | |
| 638 | * concatenated into a single string. | |
| 639 | */ | |
| 640 | private String append( final TextEditor editor ) { | |
| 641 | final var pattern = editor.getPath(); | |
| 642 | final var parent = pattern.getParent(); | |
| 643 | ||
| 644 | // Short-circuit because nothing else can be done. | |
| 645 | if( parent == null ) { | |
| 646 | clue( "Main.status.export.concat.parent", pattern ); | |
| 647 | return editor.getText(); | |
| 648 | } | |
| 649 | ||
| 650 | final var filename = SysFile.getFileName( pattern ); | |
| 651 | final var extension = getExtension( filename ); | |
| 652 | ||
| 653 | if( extension.isBlank() ) { | |
| 654 | clue( "Main.status.export.concat.extension", filename ); | |
| 655 | return editor.getText(); | |
| 656 | } | |
| 657 | ||
| 658 | try { | |
| 659 | final var command = new ConcatenateCommand( | |
| 660 | parent, extension, getString( KEY_TYPESET_CONTEXT_CHAPTERS ) ); | |
| 661 | return command.call(); | |
| 662 | } catch( final Throwable t ) { | |
| 663 | clue( t ); | |
| 664 | return editor.getText(); | |
| 665 | } | |
| 666 | } | |
| 667 | ||
| 668 | private Optional<File> pickFile() { | |
| 669 | final var editor = getActiveTextEditor(); | |
| 670 | final var file = editor == null ? USER_DIRECTORY : editor.getFile(); | |
| 671 | final var path = SysFile.toFile( file.toPath() ); | |
| 672 | final var parent = Path.of( path.getParent() ); | |
| 673 | ||
| 674 | return new OpenUrlDialog( getWindow(), parent ).showAndWait(); | |
| 655 | 675 | } |
| 656 | 676 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.ui.controls; |
| 3 | 6 |
| 1 | /* | |
| 2 | * Copyright 2015 Karl Tauber <karl at jformdesigner dot com> | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 27 | ||
| 28 | package com.keenwrite.ui.controls; | |
| 29 | ||
| 30 | import com.keenwrite.Messages; | |
| 31 | import javafx.beans.property.ObjectProperty; | |
| 32 | import javafx.beans.property.SimpleObjectProperty; | |
| 33 | import javafx.event.ActionEvent; | |
| 34 | import javafx.scene.control.Button; | |
| 35 | import javafx.scene.control.Tooltip; | |
| 36 | import javafx.scene.input.KeyCode; | |
| 37 | import javafx.scene.input.KeyEvent; | |
| 38 | import javafx.stage.FileChooser; | |
| 39 | import javafx.stage.FileChooser.ExtensionFilter; | |
| 40 | ||
| 41 | import java.io.File; | |
| 42 | import java.nio.file.Path; | |
| 43 | import java.util.ArrayList; | |
| 44 | import java.util.List; | |
| 45 | ||
| 46 | import static com.keenwrite.io.SysFile.toFile; | |
| 47 | import static com.keenwrite.ui.fonts.IconFactory.createGraphic; | |
| 48 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.FILE_ALT; | |
| 49 | ||
| 50 | /** | |
| 51 | * Button that opens a file chooser to select a local file for a URL. | |
| 52 | */ | |
| 53 | public class BrowseFileButton extends Button { | |
| 54 | ||
| 55 | private final List<ExtensionFilter> mExtensionFilters = new ArrayList<>(); | |
| 56 | private final ObjectProperty<Path> mBasePath = new SimpleObjectProperty<>(); | |
| 57 | private final ObjectProperty<String> mUrl = new SimpleObjectProperty<>(); | |
| 58 | ||
| 59 | public BrowseFileButton() { | |
| 60 | setGraphic( createGraphic( FILE_ALT ) ); | |
| 61 | setTooltip( new Tooltip( Messages.get( "BrowseFileButton.tooltip" ) ) ); | |
| 62 | setOnAction( this::browse ); | |
| 63 | ||
| 64 | disableProperty().bind( mBasePath.isNull() ); | |
| 65 | ||
| 66 | // workaround for a JavaFX bug: | |
| 67 | // avoid closing the dialog that contains this control when the user | |
| 68 | // closes the FileChooser or DirectoryChooser using the ESC key | |
| 69 | addEventHandler( KeyEvent.KEY_RELEASED, e -> { | |
| 70 | if( e.getCode() == KeyCode.ESCAPE ) { | |
| 71 | e.consume(); | |
| 72 | } | |
| 73 | } ); | |
| 74 | } | |
| 75 | ||
| 76 | public void addExtensionFilter( ExtensionFilter extensionFilter ) { | |
| 77 | mExtensionFilters.add( extensionFilter ); | |
| 78 | } | |
| 79 | ||
| 80 | public ObjectProperty<String> urlProperty() { | |
| 81 | return mUrl; | |
| 82 | } | |
| 83 | ||
| 84 | private void browse( ActionEvent e ) { | |
| 85 | var fileChooser = new FileChooser(); | |
| 86 | fileChooser.setTitle( Messages.get( "BrowseFileButton.chooser.title" ) ); | |
| 87 | fileChooser.getExtensionFilters().addAll( mExtensionFilters ); | |
| 88 | fileChooser.getExtensionFilters() | |
| 89 | .add( new ExtensionFilter( Messages.get( | |
| 90 | "BrowseFileButton.chooser.allFilesFilter" ), "*.*" ) ); | |
| 91 | fileChooser.setInitialDirectory( getInitialDirectory() ); | |
| 92 | var result = fileChooser.showOpenDialog( getScene().getWindow() ); | |
| 93 | if( result != null ) { | |
| 94 | updateUrl( result ); | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | private File getInitialDirectory() { | |
| 99 | //TODO build initial directory based on current value of 'url' property | |
| 100 | return toFile( getBasePath() ); | |
| 101 | } | |
| 102 | ||
| 103 | private void updateUrl( File file ) { | |
| 104 | String newUrl; | |
| 105 | try { | |
| 106 | newUrl = getBasePath().relativize( file.toPath() ).toString(); | |
| 107 | } catch( final Exception ex ) { | |
| 108 | newUrl = file.toString(); | |
| 109 | } | |
| 110 | mUrl.set( newUrl.replace( '\\', '/' ) ); | |
| 111 | } | |
| 112 | ||
| 113 | public void setBasePath( Path basePath ) { | |
| 114 | this.mBasePath.set( basePath ); | |
| 115 | } | |
| 116 | ||
| 117 | private Path getBasePath() { | |
| 118 | return mBasePath.get(); | |
| 119 | } | |
| 120 | } | |
| 121 | 1 |
| 1 | /* | |
| 2 | * Copyright 2020 Karl Tauber and White Magic Software, Ltd. | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 27 | ||
| 28 | package com.keenwrite.ui.controls; | |
| 29 | ||
| 30 | import javafx.beans.property.SimpleStringProperty; | |
| 31 | import javafx.beans.property.StringProperty; | |
| 32 | import javafx.scene.control.TextField; | |
| 33 | import javafx.util.StringConverter; | |
| 34 | ||
| 35 | /** | |
| 36 | * Responsible for escaping/unescaping characters for Markdown. | |
| 37 | */ | |
| 38 | public class EscapeTextField extends TextField { | |
| 39 | ||
| 40 | public EscapeTextField() { | |
| 41 | escapedText.bindBidirectional( | |
| 42 | textProperty(), | |
| 43 | new StringConverter<>() { | |
| 44 | @Override | |
| 45 | public String toString( String object ) { | |
| 46 | return escape( object ); | |
| 47 | } | |
| 48 | ||
| 49 | @Override | |
| 50 | public String fromString( String string ) { | |
| 51 | return unescape( string ); | |
| 52 | } | |
| 53 | } | |
| 54 | ); | |
| 55 | escapeCharacters.addListener( | |
| 56 | e -> escapedText.set( escape( textProperty().get() ) ) | |
| 57 | ); | |
| 58 | } | |
| 59 | ||
| 60 | // 'escapedText' property | |
| 61 | private final StringProperty escapedText = new SimpleStringProperty(); | |
| 62 | ||
| 63 | public StringProperty escapedTextProperty() { | |
| 64 | return escapedText; | |
| 65 | } | |
| 66 | ||
| 67 | // 'escapeCharacters' property | |
| 68 | private final StringProperty escapeCharacters = new SimpleStringProperty(); | |
| 69 | ||
| 70 | public String getEscapeCharacters() { | |
| 71 | return escapeCharacters.get(); | |
| 72 | } | |
| 73 | ||
| 74 | public void setEscapeCharacters( String escapeCharacters ) { | |
| 75 | this.escapeCharacters.set( escapeCharacters ); | |
| 76 | } | |
| 77 | ||
| 78 | private String escape( final String s ) { | |
| 79 | final String escapeChars = getEscapeCharacters(); | |
| 80 | ||
| 81 | return isEmpty( escapeChars ) ? s : | |
| 82 | s.replaceAll( "([" + escapeChars.replaceAll( | |
| 83 | "(.)", | |
| 84 | "\\\\$1" ) + "])", "\\\\$1" ); | |
| 85 | } | |
| 86 | ||
| 87 | private String unescape( final String s ) { | |
| 88 | final String escapeChars = getEscapeCharacters(); | |
| 89 | ||
| 90 | return isEmpty( escapeChars ) ? s : | |
| 91 | s.replaceAll( "\\\\([" + escapeChars | |
| 92 | .replaceAll( "(.)", "\\\\$1" ) + "])", "$1" ); | |
| 93 | } | |
| 94 | ||
| 95 | private static boolean isEmpty( final String s ) { | |
| 96 | return s == null || s.isEmpty(); | |
| 97 | } | |
| 98 | } | |
| 99 | 1 |
| 1 | /* Copyright 2017-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.ui.dialogs; |
| 3 | 6 | |
| 4 | 7 | import com.keenwrite.service.events.impl.ButtonOrderPane; |
| 5 | 8 | import javafx.scene.control.Dialog; |
| 6 | 9 | import javafx.stage.Stage; |
| 7 | 10 | import javafx.stage.Window; |
| 8 | 11 | |
| 9 | 12 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; |
| 10 | 13 | import static com.keenwrite.Messages.get; |
| 14 | import static com.keenwrite.util.Strings.validate; | |
| 11 | 15 | import static javafx.scene.control.ButtonType.CANCEL; |
| 12 | 16 | import static javafx.scene.control.ButtonType.OK; |
| ... | ||
| 25 | 29 | * @param title The messages title to display in the title bar. |
| 26 | 30 | */ |
| 27 | @SuppressWarnings( "OverridableMethodCallInConstructor" ) | |
| 28 | 31 | public AbstractDialog( final Window owner, final String title ) { |
| 32 | assert owner != null; | |
| 33 | assert validate( title ); | |
| 34 | ||
| 29 | 35 | setTitle( get( title ) ); |
| 30 | 36 | setResizable( true ); |
| ... | ||
| 66 | 72 | protected final void initCloseAction() { |
| 67 | 73 | final var window = getDialogPane().getScene().getWindow(); |
| 68 | window.setOnCloseRequest( event -> window.hide() ); | |
| 74 | window.setOnCloseRequest( _ -> window.hide() ); | |
| 69 | 75 | } |
| 70 | 76 | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.dialogs; | |
| 6 | ||
| 7 | import com.keenwrite.Messages; | |
| 8 | import com.keenwrite.service.events.impl.ButtonOrderPane; | |
| 9 | import javafx.application.Platform; | |
| 10 | import javafx.beans.value.ChangeListener; | |
| 11 | import javafx.geometry.Insets; | |
| 12 | import javafx.scene.control.ButtonBar.ButtonData; | |
| 13 | import javafx.scene.control.Dialog; | |
| 14 | import javafx.scene.control.Label; | |
| 15 | import javafx.scene.control.TextField; | |
| 16 | import javafx.scene.layout.ColumnConstraints; | |
| 17 | import javafx.scene.layout.GridPane; | |
| 18 | import javafx.stage.Window; | |
| 19 | ||
| 20 | import java.util.LinkedList; | |
| 21 | import java.util.List; | |
| 22 | ||
| 23 | import static com.keenwrite.Messages.get; | |
| 24 | import static com.keenwrite.util.Strings.validate; | |
| 25 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 26 | import static javafx.scene.control.ButtonType.OK; | |
| 27 | import static javafx.scene.layout.Priority.ALWAYS; | |
| 28 | import static javafx.scene.layout.Priority.NEVER; | |
| 29 | ||
| 30 | /** | |
| 31 | * TODO: This class could be combined with {@link AbstractDialog}, either | |
| 32 | * directly or through inheritance. | |
| 33 | * | |
| 34 | * @param <T> The type of data returned from the dialog upon acceptance. | |
| 35 | */ | |
| 36 | public abstract class CustomDialog<T> extends Dialog<T> { | |
| 37 | private final GridPane mContentPane = new GridPane( 10, 10 ); | |
| 38 | private final List<TextField> mInputFields = new LinkedList<>(); | |
| 39 | ||
| 40 | public CustomDialog( final Window owner, final String title ) { | |
| 41 | assert owner != null; | |
| 42 | assert validate( title ); | |
| 43 | ||
| 44 | initOwner( owner ); | |
| 45 | setTitle( get( title ) ); | |
| 46 | setResizable( true ); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Allows for late binding so that input fields can be populated after | |
| 51 | * the constructor is called. | |
| 52 | */ | |
| 53 | protected void initialize() { | |
| 54 | initDialogPane(); | |
| 55 | initDialogButtons(); | |
| 56 | initInputFields(); | |
| 57 | initContentPane(); | |
| 58 | ||
| 59 | assert !mInputFields.isEmpty(); | |
| 60 | ||
| 61 | final var first = mInputFields.getFirst(); | |
| 62 | assert first != null; | |
| 63 | ||
| 64 | Platform.runLater( first::requestFocus ); | |
| 65 | ||
| 66 | setResultConverter( button -> { | |
| 67 | final ButtonData data = button == null ? null : button.getButtonData(); | |
| 68 | return data == ButtonData.OK_DONE ? handleAccept() : null; | |
| 69 | } ); | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Invoked when the user selects the OK button to confirm the input values. | |
| 74 | * | |
| 75 | * @return The type of data provided by using the dialog. | |
| 76 | */ | |
| 77 | protected abstract T handleAccept(); | |
| 78 | ||
| 79 | /** | |
| 80 | * Subclasses must call this method at least once. | |
| 81 | * | |
| 82 | * @param id The unique identifier for the input field. | |
| 83 | * @param label The input field's label property key. | |
| 84 | * @param prompt The prompt property key, which provides context. | |
| 85 | * @param value The initial value to provide for the field. | |
| 86 | * @see Messages#get(String) | |
| 87 | */ | |
| 88 | protected void addInputField( | |
| 89 | final String id, | |
| 90 | final String label, | |
| 91 | final String prompt, | |
| 92 | final String value, | |
| 93 | final ChangeListener<String> listener ) { | |
| 94 | assert validate( id ); | |
| 95 | assert validate( label ); | |
| 96 | assert validate( prompt ); | |
| 97 | assert validate( value ); | |
| 98 | ||
| 99 | final int row = mInputFields.size(); | |
| 100 | final Label fieldLabel = new Label( get( label ) ); | |
| 101 | final TextField fieldInput = new TextField(); | |
| 102 | ||
| 103 | fieldInput.setPromptText( get( prompt ) ); | |
| 104 | fieldInput.setId( id ); | |
| 105 | fieldInput.textProperty().addListener( listener ); | |
| 106 | fieldInput.setText( value ); | |
| 107 | ||
| 108 | mContentPane.add( fieldLabel, 0, row ); | |
| 109 | mContentPane.add( fieldInput, 1, row ); | |
| 110 | mInputFields.add( fieldInput ); | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Subclasses must add at least one input field. | |
| 115 | */ | |
| 116 | protected abstract void initInputFields(); | |
| 117 | ||
| 118 | /** | |
| 119 | * Set the dialog to use a button order pane with an OK and a CANCEL button. | |
| 120 | */ | |
| 121 | protected void initDialogPane() { | |
| 122 | setDialogPane( new ButtonOrderPane() ); | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Set an OK and CANCEL button on the dialog. | |
| 127 | */ | |
| 128 | protected void initDialogButtons() { | |
| 129 | getDialogPane().getButtonTypes().addAll( OK, CANCEL ); | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Called after the input fields have been added. This adds the input | |
| 134 | * fields to the main dialog pane. | |
| 135 | */ | |
| 136 | protected void initContentPane() { | |
| 137 | mContentPane.setPadding( new Insets( 20, 10, 10, 10 ) ); | |
| 138 | ||
| 139 | final var cc1 = new ColumnConstraints(); | |
| 140 | final var cc2 = new ColumnConstraints(); | |
| 141 | ||
| 142 | cc1.setHgrow( NEVER ); | |
| 143 | cc2.setHgrow( ALWAYS ); | |
| 144 | cc2.setMinWidth( 250 ); | |
| 145 | mContentPane.getColumnConstraints().addAll( cc1, cc2 ); | |
| 146 | ||
| 147 | getDialogPane().setContent( mContentPane ); | |
| 148 | } | |
| 149 | } | |
| 1 | 150 |
| 43 | 43 | import static com.keenwrite.io.SysFile.toFile; |
| 44 | 44 | import static com.keenwrite.util.FileWalker.walk; |
| 45 | import static com.keenwrite.util.Strings.abbreviate; | |
| 45 | 46 | import static java.lang.Math.max; |
| 46 | 47 | import static java.nio.charset.StandardCharsets.UTF_8; |
| 47 | 48 | import static javafx.application.Platform.runLater; |
| 48 | 49 | import static javafx.geometry.Pos.CENTER; |
| 49 | 50 | import static javafx.scene.control.ButtonType.OK; |
| 50 | import static org.apache.commons.lang3.StringUtils.abbreviate; | |
| 51 | 51 | |
| 52 | 52 | /** |
| ... | ||
| 319 | 319 | |
| 320 | 320 | textField.textProperty().addListener( |
| 321 | ( c, o, n ) -> textField.setText( RangeValidator.normalize( n ) ) | |
| 321 | ( _, _, n ) -> textField.setText( RangeValidator.normalize( n ) ) | |
| 322 | 322 | ); |
| 323 | 323 | |
| 324 | 324 | return textField; |
| 325 | 325 | } |
| 326 | 326 | |
| 327 | 327 | private Label createLabel( final String key ) { |
| 328 | final var label = new Label( get( key ) + ":" ); | |
| 328 | final var label = new Label( STR."\{get( key )}:" ); | |
| 329 | 329 | final var font = label.getFont(); |
| 330 | 330 | final var upscale = new Font( font.getName(), 14 ); |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.dialogs; | |
| 6 | ||
| 7 | import com.keenwrite.ui.models.HyperlinkModel; | |
| 8 | import javafx.stage.Window; | |
| 9 | ||
| 10 | /** | |
| 11 | * Dialog to insert or edit a Markdown link. | |
| 12 | */ | |
| 13 | public final class HyperlinkDialog extends CustomDialog<String> { | |
| 14 | private static final String PREFIX = "Dialog.link."; | |
| 15 | ||
| 16 | /** | |
| 17 | * Contains information about the hyperlink at the caret position in the | |
| 18 | * document, if a hyperlink is present at that location. This allows users | |
| 19 | * to edit existing hyperlinks using this {@link HyperlinkDialog}. | |
| 20 | */ | |
| 21 | private final HyperlinkModel mModel; | |
| 22 | ||
| 23 | /** | |
| 24 | * @param owner {@link Window} responsible for the dialog resource. | |
| 25 | * @param model Existing hyperlink data, or blank for a new link. | |
| 26 | */ | |
| 27 | public HyperlinkDialog( final Window owner, final HyperlinkModel model ) { | |
| 28 | super( owner, STR."\{PREFIX}title" ); | |
| 29 | ||
| 30 | mModel = model; | |
| 31 | ||
| 32 | super.initialize(); | |
| 33 | } | |
| 34 | ||
| 35 | @Override | |
| 36 | protected void initInputFields() { | |
| 37 | addInputField( | |
| 38 | "text", | |
| 39 | STR."\{PREFIX}label.text", STR."\{PREFIX}prompt.text", | |
| 40 | mModel.getText(), | |
| 41 | ( _, _, n ) -> mModel.setText( n ) | |
| 42 | ); | |
| 43 | addInputField( | |
| 44 | "url", | |
| 45 | STR."\{PREFIX}label.url", STR."\{PREFIX}prompt.url", | |
| 46 | mModel.getUrl(), | |
| 47 | ( _, _, n ) -> mModel.setUrl( n ) | |
| 48 | ); | |
| 49 | addInputField( | |
| 50 | "title", | |
| 51 | STR."\{PREFIX}label.title", STR."\{PREFIX}prompt.title", | |
| 52 | mModel.getTitle(), | |
| 53 | ( _, _, n ) -> mModel.setTitle( n ) | |
| 54 | ); | |
| 55 | } | |
| 56 | ||
| 57 | @Override | |
| 58 | protected String handleAccept() { | |
| 59 | return mModel.toString(); | |
| 60 | } | |
| 61 | } | |
| 1 | 62 |
| 1 | /* | |
| 2 | * Copyright 2015 Karl Tauber <karl at jformdesigner dot com> | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 14 | 2 | * |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 3 | * SPDX-License-Identifier: MIT | |
| 26 | 4 | */ |
| 27 | 5 | package com.keenwrite.ui.dialogs; |
| 28 | 6 | |
| 29 | import static com.keenwrite.Messages.get; | |
| 30 | import com.keenwrite.ui.controls.BrowseFileButton; | |
| 31 | import com.keenwrite.ui.controls.EscapeTextField; | |
| 32 | import java.nio.file.Path; | |
| 33 | import javafx.application.Platform; | |
| 34 | import javafx.beans.binding.Bindings; | |
| 35 | import javafx.beans.property.SimpleStringProperty; | |
| 36 | import javafx.beans.property.StringProperty; | |
| 37 | import javafx.scene.control.ButtonBar.ButtonData; | |
| 38 | import static javafx.scene.control.ButtonType.OK; | |
| 39 | import javafx.scene.control.DialogPane; | |
| 40 | import javafx.scene.control.Label; | |
| 41 | import javafx.stage.FileChooser.ExtensionFilter; | |
| 7 | import com.keenwrite.ui.models.ImageModel; | |
| 42 | 8 | import javafx.stage.Window; |
| 43 | import org.tbee.javafx.scene.layout.fxml.MigPane; | |
| 44 | 9 | |
| 45 | 10 | /** |
| 46 | 11 | * Dialog to enter a Markdown image. |
| 47 | 12 | */ |
| 48 | public class ImageDialog extends AbstractDialog<String> { | |
| 49 | ||
| 50 | private final StringProperty image = new SimpleStringProperty(); | |
| 51 | ||
| 52 | public ImageDialog( final Window owner, final Path basePath ) { | |
| 53 | super(owner, "Dialog.image.title" ); | |
| 54 | ||
| 55 | final DialogPane dialogPane = getDialogPane(); | |
| 56 | dialogPane.setContent( pane ); | |
| 57 | ||
| 58 | linkBrowseFileButton.setBasePath( basePath ); | |
| 59 | linkBrowseFileButton.addExtensionFilter( new ExtensionFilter( get( "Dialog.image.chooser.imagesFilter" ), "*.png", "*.gif", "*.jpg" ) ); | |
| 60 | linkBrowseFileButton.urlProperty().bindBidirectional( urlField.escapedTextProperty() ); | |
| 61 | ||
| 62 | dialogPane.lookupButton( OK ).disableProperty().bind( | |
| 63 | urlField.escapedTextProperty().isEmpty() | |
| 64 | .or( textField.escapedTextProperty().isEmpty() ) ); | |
| 13 | public class ImageDialog extends CustomDialog<String> { | |
| 14 | private static final String PREFIX = "Dialog.image."; | |
| 65 | 15 | |
| 66 | image.bind( Bindings.when( titleField.escapedTextProperty().isNotEmpty() ) | |
| 67 | .then( Bindings.format( "", textField.escapedTextProperty(), urlField.escapedTextProperty(), titleField.escapedTextProperty() ) ) | |
| 68 | .otherwise( Bindings.format( "", textField.escapedTextProperty(), urlField.escapedTextProperty() ) ) ); | |
| 69 | previewField.textProperty().bind( image ); | |
| 16 | private final ImageModel mModel; | |
| 70 | 17 | |
| 71 | setResultConverter( dialogButton -> { | |
| 72 | ButtonData data = dialogButton != null ? dialogButton.getButtonData() : null; | |
| 73 | return data == ButtonData.OK_DONE ? image.get() : null; | |
| 74 | } ); | |
| 18 | public ImageDialog( final Window owner, final ImageModel model ) { | |
| 19 | super( owner, STR."\{PREFIX}title" ); | |
| 75 | 20 | |
| 76 | Platform.runLater( () -> { | |
| 77 | urlField.requestFocus(); | |
| 21 | mModel = model; | |
| 78 | 22 | |
| 79 | if( urlField.getText().startsWith( "http://" ) ) { | |
| 80 | urlField.selectRange( "http://".length(), urlField.getLength() ); | |
| 81 | } | |
| 82 | } ); | |
| 23 | super.initialize(); | |
| 83 | 24 | } |
| 84 | 25 | |
| 85 | 26 | @Override |
| 86 | protected void initComponents() { | |
| 87 | // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents | |
| 88 | pane = new MigPane(); | |
| 89 | Label urlLabel = new Label(); | |
| 90 | urlField = new EscapeTextField(); | |
| 91 | linkBrowseFileButton = new BrowseFileButton(); | |
| 92 | Label textLabel = new Label(); | |
| 93 | textField = new EscapeTextField(); | |
| 94 | Label titleLabel = new Label(); | |
| 95 | titleField = new EscapeTextField(); | |
| 96 | Label previewLabel = new Label(); | |
| 97 | previewField = new Label(); | |
| 98 | ||
| 99 | //======== pane ======== | |
| 100 | { | |
| 101 | pane.setCols( "[shrink 0,fill][300,grow,fill][fill]" ); | |
| 102 | pane.setRows( "[][][][]" ); | |
| 103 | ||
| 104 | //---- urlLabel ---- | |
| 105 | urlLabel.setText( get( "Dialog.image.urlLabel.text" ) ); | |
| 106 | pane.add( urlLabel, "cell 0 0" ); | |
| 107 | ||
| 108 | //---- urlField ---- | |
| 109 | urlField.setEscapeCharacters( "()" ); | |
| 110 | urlField.setText( "https://yourlink.com" ); | |
| 111 | urlField.setPromptText( "https://yourlink.com" ); | |
| 112 | pane.add( urlField, "cell 1 0" ); | |
| 113 | pane.add( linkBrowseFileButton, "cell 2 0" ); | |
| 114 | ||
| 115 | //---- textLabel ---- | |
| 116 | textLabel.setText( get( "Dialog.image.textLabel.text" ) ); | |
| 117 | pane.add( textLabel, "cell 0 1" ); | |
| 118 | ||
| 119 | //---- textField ---- | |
| 120 | textField.setEscapeCharacters( "[]" ); | |
| 121 | pane.add( textField, "cell 1 1 2 1" ); | |
| 122 | ||
| 123 | //---- titleLabel ---- | |
| 124 | titleLabel.setText( get( "Dialog.image.titleLabel.text" ) ); | |
| 125 | pane.add( titleLabel, "cell 0 2" ); | |
| 126 | pane.add( titleField, "cell 1 2 2 1" ); | |
| 127 | ||
| 128 | //---- previewLabel ---- | |
| 129 | previewLabel.setText( get( "Dialog.image.previewLabel.text" ) ); | |
| 130 | pane.add( previewLabel, "cell 0 3" ); | |
| 131 | pane.add( previewField, "cell 1 3 2 1" ); | |
| 132 | } | |
| 133 | // JFormDesigner - End of component initialization //GEN-END:initComponents | |
| 27 | protected void initInputFields() { | |
| 28 | addInputField( | |
| 29 | "url", | |
| 30 | STR."\{PREFIX}label.url", STR."\{PREFIX}prompt.url", | |
| 31 | mModel.getUrl(), | |
| 32 | ( _, _, n ) -> mModel.setUrl( n ) | |
| 33 | ); | |
| 34 | addInputField( | |
| 35 | "text", | |
| 36 | STR."\{PREFIX}label.text", STR."\{PREFIX}prompt.text", | |
| 37 | mModel.getText(), | |
| 38 | ( _, _, n ) -> mModel.setText( n ) | |
| 39 | ); | |
| 40 | addInputField( | |
| 41 | "title", | |
| 42 | STR."\{PREFIX}label.title", STR."\{PREFIX}prompt.title", | |
| 43 | mModel.getTitle(), | |
| 44 | ( _, _, n ) -> mModel.setTitle( n ) | |
| 45 | ); | |
| 134 | 46 | } |
| 135 | 47 | |
| 136 | // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables | |
| 137 | private MigPane pane; | |
| 138 | private EscapeTextField urlField; | |
| 139 | private BrowseFileButton linkBrowseFileButton; | |
| 140 | private EscapeTextField textField; | |
| 141 | private EscapeTextField titleField; | |
| 142 | private Label previewField; | |
| 143 | // JFormDesigner - End of variables declaration //GEN-END:variables | |
| 48 | @Override | |
| 49 | protected String handleAccept() { | |
| 50 | return mModel.toString(); | |
| 51 | } | |
| 144 | 52 | } |
| 145 | 53 |
| 1 | JFDML JFormDesigner: "9.9.9.9.9999" Java: "1.8.0_66" encoding: "UTF-8" | |
| 2 | ||
| 3 | new FormModel { | |
| 4 | "i18n.bundlePackage": "com.scrivendor" | |
| 5 | "i18n.bundleName": "messages" | |
| 6 | "i18n.autoExternalize": true | |
| 7 | "i18n.keyPrefix": "ImageDialog" | |
| 8 | contentType: "form/javafx" | |
| 9 | root: new FormRoot { | |
| 10 | add( new FormContainer( "org.tbee.javafx.scene.layout.fxml.MigPane", new FormLayoutManager( class org.tbee.javafx.scene.layout.fxml.MigPane ) { | |
| 11 | "$layoutConstraints": "" | |
| 12 | "$columnConstraints": "[shrink 0,fill][300,grow,fill][fill]" | |
| 13 | "$rowConstraints": "[][][][]" | |
| 14 | } ) { | |
| 15 | name: "pane" | |
| 16 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 17 | name: "urlLabel" | |
| 18 | "text": new FormMessage( null, "ImageDialog.urlLabel.text" ) | |
| 19 | auxiliary() { | |
| 20 | "JavaCodeGenerator.variableLocal": true | |
| 21 | } | |
| 22 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 23 | "value": "cell 0 0" | |
| 24 | } ) | |
| 25 | add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) { | |
| 26 | name: "urlField" | |
| 27 | "escapeCharacters": "()" | |
| 28 | "text": "http://yourlink.com" | |
| 29 | "promptText": "http://yourlink.com" | |
| 30 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 31 | "value": "cell 1 0" | |
| 32 | } ) | |
| 33 | add( new FormComponent( "com.scrivendor.controls.BrowseFileButton" ) { | |
| 34 | name: "linkBrowseFileButton" | |
| 35 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 36 | "value": "cell 2 0" | |
| 37 | } ) | |
| 38 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 39 | name: "textLabel" | |
| 40 | "text": new FormMessage( null, "ImageDialog.textLabel.text" ) | |
| 41 | auxiliary() { | |
| 42 | "JavaCodeGenerator.variableLocal": true | |
| 43 | } | |
| 44 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 45 | "value": "cell 0 1" | |
| 46 | } ) | |
| 47 | add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) { | |
| 48 | name: "textField" | |
| 49 | "escapeCharacters": "[]" | |
| 50 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 51 | "value": "cell 1 1 2 1" | |
| 52 | } ) | |
| 53 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 54 | name: "titleLabel" | |
| 55 | "text": new FormMessage( null, "ImageDialog.titleLabel.text" ) | |
| 56 | auxiliary() { | |
| 57 | "JavaCodeGenerator.variableLocal": true | |
| 58 | } | |
| 59 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 60 | "value": "cell 0 2" | |
| 61 | } ) | |
| 62 | add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) { | |
| 63 | name: "titleField" | |
| 64 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 65 | "value": "cell 1 2 2 1" | |
| 66 | } ) | |
| 67 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 68 | name: "previewLabel" | |
| 69 | "text": new FormMessage( null, "ImageDialog.previewLabel.text" ) | |
| 70 | auxiliary() { | |
| 71 | "JavaCodeGenerator.variableLocal": true | |
| 72 | } | |
| 73 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 74 | "value": "cell 0 3" | |
| 75 | } ) | |
| 76 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 77 | name: "previewField" | |
| 78 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 79 | "value": "cell 1 3 2 1" | |
| 80 | } ) | |
| 81 | }, new FormLayoutConstraints( null ) { | |
| 82 | "location": new javafx.geometry.Point2D( 0.0, 0.0 ) | |
| 83 | "size": new javafx.geometry.Dimension2D( 500.0, 300.0 ) | |
| 84 | } ) | |
| 85 | } | |
| 86 | } | |
| 87 | 1 |
| 1 | /* | |
| 2 | * Copyright 2016 Karl Tauber and White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * 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.keenwrite.ui.dialogs; | |
| 29 | ||
| 30 | import com.keenwrite.ui.controls.EscapeTextField; | |
| 31 | import com.keenwrite.editors.markdown.HyperlinkModel; | |
| 32 | import javafx.application.Platform; | |
| 33 | import javafx.beans.binding.Bindings; | |
| 34 | import javafx.beans.property.SimpleStringProperty; | |
| 35 | import javafx.beans.property.StringProperty; | |
| 36 | import javafx.scene.control.ButtonBar.ButtonData; | |
| 37 | import javafx.scene.control.DialogPane; | |
| 38 | import javafx.scene.control.Label; | |
| 39 | import javafx.stage.Window; | |
| 40 | import org.tbee.javafx.scene.layout.fxml.MigPane; | |
| 41 | ||
| 42 | import static com.keenwrite.Messages.get; | |
| 43 | import static javafx.scene.control.ButtonType.OK; | |
| 44 | ||
| 45 | /** | |
| 46 | * Dialog to enter a Markdown link. | |
| 47 | */ | |
| 48 | public class LinkDialog extends AbstractDialog<String> { | |
| 49 | ||
| 50 | private final StringProperty link = new SimpleStringProperty(); | |
| 51 | ||
| 52 | public LinkDialog( | |
| 53 | final Window owner, final HyperlinkModel hyperlink ) { | |
| 54 | super( owner, "Dialog.link.title" ); | |
| 55 | ||
| 56 | final DialogPane dialogPane = getDialogPane(); | |
| 57 | dialogPane.setContent( pane ); | |
| 58 | ||
| 59 | dialogPane.lookupButton( OK ).disableProperty().bind( | |
| 60 | urlField.escapedTextProperty().isEmpty() ); | |
| 61 | ||
| 62 | textField.setText( hyperlink.getText() ); | |
| 63 | urlField.setText( hyperlink.getUrl() ); | |
| 64 | titleField.setText( hyperlink.getTitle() ); | |
| 65 | ||
| 66 | link.bind( Bindings.when( titleField.escapedTextProperty().isNotEmpty() ) | |
| 67 | .then( Bindings.format( "[%s](%s \"%s\")", textField.escapedTextProperty(), urlField.escapedTextProperty(), titleField.escapedTextProperty() ) ) | |
| 68 | .otherwise( Bindings.when( textField.escapedTextProperty().isNotEmpty() ) | |
| 69 | .then( Bindings.format( "[%s](%s)", textField.escapedTextProperty(), urlField.escapedTextProperty() ) ) | |
| 70 | .otherwise( urlField.escapedTextProperty() ) ) ); | |
| 71 | ||
| 72 | setResultConverter( dialogButton -> { | |
| 73 | ButtonData data = dialogButton != null ? dialogButton.getButtonData() : null; | |
| 74 | return data == ButtonData.OK_DONE ? link.get() : null; | |
| 75 | } ); | |
| 76 | ||
| 77 | Platform.runLater( () -> { | |
| 78 | urlField.requestFocus(); | |
| 79 | urlField.selectRange( 0, urlField.getLength() ); | |
| 80 | } ); | |
| 81 | } | |
| 82 | ||
| 83 | @Override | |
| 84 | protected void initComponents() { | |
| 85 | // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents | |
| 86 | pane = new MigPane(); | |
| 87 | Label urlLabel = new Label(); | |
| 88 | urlField = new EscapeTextField(); | |
| 89 | Label textLabel = new Label(); | |
| 90 | textField = new EscapeTextField(); | |
| 91 | Label titleLabel = new Label(); | |
| 92 | titleField = new EscapeTextField(); | |
| 93 | ||
| 94 | //======== pane ======== | |
| 95 | { | |
| 96 | pane.setCols( "[shrink 0,fill][300,grow,fill][fill][fill]" ); | |
| 97 | pane.setRows( "[][][][]" ); | |
| 98 | ||
| 99 | //---- urlLabel ---- | |
| 100 | urlLabel.setText( get( "Dialog.link.urlLabel.text" ) ); | |
| 101 | pane.add( urlLabel, "cell 0 0" ); | |
| 102 | ||
| 103 | //---- urlField ---- | |
| 104 | urlField.setEscapeCharacters( "()" ); | |
| 105 | pane.add( urlField, "cell 1 0" ); | |
| 106 | ||
| 107 | //---- textLabel ---- | |
| 108 | textLabel.setText( get( "Dialog.link.textLabel.text" ) ); | |
| 109 | pane.add( textLabel, "cell 0 1" ); | |
| 110 | ||
| 111 | //---- textField ---- | |
| 112 | textField.setEscapeCharacters( "[]" ); | |
| 113 | pane.add( textField, "cell 1 1 3 1" ); | |
| 114 | ||
| 115 | //---- titleLabel ---- | |
| 116 | titleLabel.setText( get( "Dialog.link.titleLabel.text" ) ); | |
| 117 | pane.add( titleLabel, "cell 0 2" ); | |
| 118 | pane.add( titleField, "cell 1 2 3 1" ); | |
| 119 | } | |
| 120 | // JFormDesigner - End of component initialization //GEN-END:initComponents | |
| 121 | } | |
| 122 | ||
| 123 | // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables | |
| 124 | private MigPane pane; | |
| 125 | private EscapeTextField urlField; | |
| 126 | private EscapeTextField textField; | |
| 127 | private EscapeTextField titleField; | |
| 128 | // JFormDesigner - End of variables declaration //GEN-END:variables | |
| 129 | } | |
| 130 | 1 |
| 1 | JFDML JFormDesigner: "9.9.9.9.9999" Java: "1.8.0_66" encoding: "UTF-8" | |
| 2 | ||
| 3 | new FormModel { | |
| 4 | "i18n.bundlePackage": "com.scrivendor" | |
| 5 | "i18n.bundleName": "messages" | |
| 6 | "i18n.autoExternalize": true | |
| 7 | "i18n.keyPrefix": "LinkDialog" | |
| 8 | contentType: "form/javafx" | |
| 9 | root: new FormRoot { | |
| 10 | add( new FormContainer( "org.tbee.javafx.scene.layout.fxml.MigPane", new FormLayoutManager( class org.tbee.javafx.scene.layout.fxml.MigPane ) { | |
| 11 | "$layoutConstraints": "" | |
| 12 | "$columnConstraints": "[shrink 0,fill][300,grow,fill][fill][fill]" | |
| 13 | "$rowConstraints": "[][][][]" | |
| 14 | } ) { | |
| 15 | name: "pane" | |
| 16 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 17 | name: "urlLabel" | |
| 18 | "text": new FormMessage( null, "LinkDialog.urlLabel.text" ) | |
| 19 | auxiliary() { | |
| 20 | "JavaCodeGenerator.variableLocal": true | |
| 21 | } | |
| 22 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 23 | "value": "cell 0 0" | |
| 24 | } ) | |
| 25 | add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) { | |
| 26 | name: "urlField" | |
| 27 | "escapeCharacters": "()" | |
| 28 | "text": "http://yourlink.com" | |
| 29 | "promptText": "http://yourlink.com" | |
| 30 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 31 | "value": "cell 1 0" | |
| 32 | } ) | |
| 33 | add( new FormComponent( "com.scrivendor.controls.BrowseDirectoryButton" ) { | |
| 34 | name: "linkBrowseDirectoyButton" | |
| 35 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 36 | "value": "cell 2 0" | |
| 37 | } ) | |
| 38 | add( new FormComponent( "com.scrivendor.controls.BrowseFileButton" ) { | |
| 39 | name: "linkBrowseFileButton" | |
| 40 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 41 | "value": "cell 3 0" | |
| 42 | } ) | |
| 43 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 44 | name: "textLabel" | |
| 45 | "text": new FormMessage( null, "LinkDialog.textLabel.text" ) | |
| 46 | auxiliary() { | |
| 47 | "JavaCodeGenerator.variableLocal": true | |
| 48 | } | |
| 49 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 50 | "value": "cell 0 1" | |
| 51 | } ) | |
| 52 | add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) { | |
| 53 | name: "textField" | |
| 54 | "escapeCharacters": "[]" | |
| 55 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 56 | "value": "cell 1 1 3 1" | |
| 57 | } ) | |
| 58 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 59 | name: "titleLabel" | |
| 60 | "text": new FormMessage( null, "LinkDialog.titleLabel.text" ) | |
| 61 | auxiliary() { | |
| 62 | "JavaCodeGenerator.variableLocal": true | |
| 63 | } | |
| 64 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 65 | "value": "cell 0 2" | |
| 66 | } ) | |
| 67 | add( new FormComponent( "com.scrivendor.controls.EscapeTextField" ) { | |
| 68 | name: "titleField" | |
| 69 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 70 | "value": "cell 1 2 3 1" | |
| 71 | } ) | |
| 72 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 73 | name: "previewLabel" | |
| 74 | "text": new FormMessage( null, "LinkDialog.previewLabel.text" ) | |
| 75 | auxiliary() { | |
| 76 | "JavaCodeGenerator.variableLocal": true | |
| 77 | } | |
| 78 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 79 | "value": "cell 0 3" | |
| 80 | } ) | |
| 81 | add( new FormComponent( "javafx.scene.control.Label" ) { | |
| 82 | name: "previewField" | |
| 83 | }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { | |
| 84 | "value": "cell 1 3 3 1" | |
| 85 | } ) | |
| 86 | }, new FormLayoutConstraints( null ) { | |
| 87 | "location": new javafx.geometry.Point2D( 0.0, 0.0 ) | |
| 88 | "size": new javafx.geometry.Dimension2D( 500.0, 300.0 ) | |
| 89 | } ) | |
| 90 | } | |
| 91 | } | |
| 92 | 1 |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.dialogs; | |
| 6 | ||
| 7 | import com.keenwrite.events.FileOpenEvent; | |
| 8 | import javafx.stage.Window; | |
| 9 | ||
| 10 | import java.io.File; | |
| 11 | import java.net.URI; | |
| 12 | import java.nio.file.Path; | |
| 13 | ||
| 14 | import static com.keenwrite.events.StatusEvent.clue; | |
| 15 | import static com.keenwrite.io.downloads.DownloadManager.*; | |
| 16 | import static com.keenwrite.util.Strings.sanitize; | |
| 17 | ||
| 18 | /** | |
| 19 | * Dialog to open a remote Markdown file. | |
| 20 | */ | |
| 21 | public final class OpenUrlDialog extends CustomDialog<File> { | |
| 22 | private static final String PREFIX = "Dialog.open_url."; | |
| 23 | private static final String DOWNLOAD = "Main.status.url.request."; | |
| 24 | private static final String STATUS = STR."\{DOWNLOAD}status."; | |
| 25 | ||
| 26 | private final Path mParent; | |
| 27 | private String mUrl = ""; | |
| 28 | ||
| 29 | /** | |
| 30 | * Ensures that all dialogs can be closed. | |
| 31 | * | |
| 32 | * @param owner The parent window of this dialog. | |
| 33 | * @param parent Directory to store downloaded file. | |
| 34 | */ | |
| 35 | public OpenUrlDialog( final Window owner, final Path parent ) { | |
| 36 | super( owner, STR."\{PREFIX}title" ); | |
| 37 | ||
| 38 | mParent = parent; | |
| 39 | ||
| 40 | super.initialize(); | |
| 41 | } | |
| 42 | ||
| 43 | @Override | |
| 44 | protected void initInputFields() { | |
| 45 | addInputField( | |
| 46 | "url", | |
| 47 | STR."\{PREFIX}label.url", STR."\{PREFIX}prompt.url", | |
| 48 | mUrl, | |
| 49 | ( _, _, n ) -> mUrl = sanitize( n ) | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | protected File handleAccept() { | |
| 55 | return mUrl.isBlank() ? null : download( mUrl ); | |
| 56 | } | |
| 57 | ||
| 58 | private File download( final String reference ) { | |
| 59 | try { | |
| 60 | clue( STR."\{DOWNLOAD}fetch", reference ); | |
| 61 | ||
| 62 | final var uri = new URI( reference ); | |
| 63 | final var path = toFile( uri ); | |
| 64 | final var basedir = path.getName(); | |
| 65 | final var file = mParent.resolve( basedir ).toFile(); | |
| 66 | ||
| 67 | if( file.exists() ) { | |
| 68 | clue( STR."\{DOWNLOAD}exists", file ); | |
| 69 | } | |
| 70 | else { | |
| 71 | final var task = downloadAsync( uri, file, ( progress, bytes ) -> { | |
| 72 | final var suffix = progress < 0 ? "bytes" : "progress"; | |
| 73 | ||
| 74 | clue( STR."\{STATUS}\{suffix}", progress, bytes ); | |
| 75 | } ); | |
| 76 | ||
| 77 | task.setOnSucceeded( _ -> { | |
| 78 | clue( STR."\{DOWNLOAD}success", file ); | |
| 79 | ||
| 80 | // Only after the download succeeds can we open the file. | |
| 81 | FileOpenEvent.fire( file.toURI() ); | |
| 82 | } ); | |
| 83 | task.setOnFailed( _ -> clue( STR."\{DOWNLOAD}failure", uri ) ); | |
| 84 | } | |
| 85 | ||
| 86 | // The return value isn't used because the download happens | |
| 87 | // asynchronously. If the download succeeds, an event is fired. | |
| 88 | return null; | |
| 89 | } catch( final Exception e ) { | |
| 90 | throw new RuntimeException( e ); | |
| 91 | } | |
| 92 | } | |
| 93 | } | |
| 1 | 94 |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.models; | |
| 6 | ||
| 7 | import com.vladsch.flexmark.ast.Link; | |
| 8 | ||
| 9 | /** | |
| 10 | * Represents the model for a hyperlink: text, url, and title. | |
| 11 | */ | |
| 12 | public final class HyperlinkModel extends ObjectModel { | |
| 13 | ||
| 14 | /** | |
| 15 | * Constructs a new hyperlink model in Markdown format by default with no | |
| 16 | * title (i.e., tooltip). | |
| 17 | * | |
| 18 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 19 | */ | |
| 20 | public HyperlinkModel( final String text ) { | |
| 21 | super( text ); | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * Constructs a new hyperlink model in Markdown format by default. | |
| 26 | * | |
| 27 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 28 | * @param url The destination URL (e.g., when clicked). | |
| 29 | * @param title The hyperlink title (e.g., shown as a tooltip). | |
| 30 | */ | |
| 31 | public HyperlinkModel( | |
| 32 | final String text, final String url, final String title ) { | |
| 33 | super( text, url, title ); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Constructs a new hyperlink model for the given AST link. | |
| 38 | * | |
| 39 | * @param link A Markdown link. | |
| 40 | */ | |
| 41 | public HyperlinkModel( final Link link ) { | |
| 42 | this( | |
| 43 | link.getText().toString(), | |
| 44 | link.getUrl().toString(), | |
| 45 | link.getTitle().toString() | |
| 46 | ); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Returns the string in Markdown format by default. | |
| 51 | * | |
| 52 | * @return A Markdown version of the hyperlink. | |
| 53 | */ | |
| 54 | @Override | |
| 55 | public String toString() { | |
| 56 | final String format = hasText() | |
| 57 | ? STR."[%s]\{hasTitle() ? "(%s \"%s\")" : "(%s%s)"}" | |
| 58 | : "%s%s%s"; | |
| 59 | ||
| 60 | // Becomes ""+URL+"" if no text is set. | |
| 61 | // Becomes [TITLE]+(URL)+"" if no title is set. | |
| 62 | // Becomes [TITLE]+(URL+ \"TITLE\") if title is set. | |
| 63 | return String.format( format, getText(), getUrl(), getTitle() ); | |
| 64 | } | |
| 65 | } | |
| 1 | 66 |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.models; | |
| 6 | ||
| 7 | /** | |
| 8 | * Represents the model for an image: text, url, and title. | |
| 9 | */ | |
| 10 | public final class ImageModel extends ObjectModel { | |
| 11 | ||
| 12 | /** | |
| 13 | * Constructs a new image model in Markdown format by default with no | |
| 14 | * title (i.e., tooltip). | |
| 15 | * | |
| 16 | * @param text The alternate text (e.g., displayed to the user). | |
| 17 | */ | |
| 18 | public ImageModel( final String text ) { | |
| 19 | super( text ); | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * Returns the string in Markdown format by default. | |
| 24 | * | |
| 25 | * @return An image reference using Markdown syntax. | |
| 26 | */ | |
| 27 | @Override | |
| 28 | public String toString() { | |
| 29 | final String format = hasText() | |
| 30 | ? STR."![%s]\{hasTitle() ? "(%s \"%s\")" : "(%s%s)"}" | |
| 31 | : "%s"; | |
| 32 | ||
| 33 | return String.format( format, getText(), getUrl(), getTitle() ); | |
| 34 | } | |
| 35 | } | |
| 1 | 36 |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.ui.models; | |
| 6 | ||
| 7 | import static com.keenwrite.util.Strings.sanitize; | |
| 8 | ||
| 9 | /** | |
| 10 | * Represents the model for an object containing text, url, and title. | |
| 11 | */ | |
| 12 | class ObjectModel { | |
| 13 | private String mText; | |
| 14 | private String mUrl; | |
| 15 | private String mTitle; | |
| 16 | ||
| 17 | /** | |
| 18 | * Constructs a new object model in Markdown format by default with no | |
| 19 | * title (i.e., tooltip). | |
| 20 | * | |
| 21 | * @param text The hyperlink text displayed (e.g., displayed to the user). | |
| 22 | */ | |
| 23 | public ObjectModel( final String text ) { | |
| 24 | this( text, null, null ); | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Constructs a new object model in Markdown format by default. | |
| 29 | * | |
| 30 | * @param text The text displayed (e.g., to the user). | |
| 31 | * @param url The destination URL (e.g., when clicked). | |
| 32 | * @param title The text title (e.g., shown as a tooltip). | |
| 33 | */ | |
| 34 | public ObjectModel( | |
| 35 | final String text, final String url, final String title ) { | |
| 36 | setText( text ); | |
| 37 | setUrl( url ); | |
| 38 | setTitle( title ); | |
| 39 | } | |
| 40 | ||
| 41 | public void setText( final String text ) { | |
| 42 | mText = sanitize( text ); | |
| 43 | } | |
| 44 | ||
| 45 | public void setUrl( final String url ) { | |
| 46 | mUrl = sanitize( url ); | |
| 47 | } | |
| 48 | ||
| 49 | public void setTitle( final String title ) { | |
| 50 | mTitle = sanitize( title ); | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Answers whether text has been set for the model. | |
| 55 | * | |
| 56 | * @return true The text description is set. | |
| 57 | */ | |
| 58 | public boolean hasText() { | |
| 59 | return !getText().isEmpty(); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Answers whether a title (tooltip) has been set for the model. | |
| 64 | * | |
| 65 | * @return true The title is set. | |
| 66 | */ | |
| 67 | public boolean hasTitle() { | |
| 68 | return !getTitle().isEmpty(); | |
| 69 | } | |
| 70 | ||
| 71 | public String getText() { | |
| 72 | return mText; | |
| 73 | } | |
| 74 | ||
| 75 | public String getUrl() { | |
| 76 | return mUrl; | |
| 77 | } | |
| 78 | ||
| 79 | public String getTitle() { | |
| 80 | return mTitle; | |
| 81 | } | |
| 82 | } | |
| 1 | 83 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.util; |
| 3 | 6 |
| 1 | /* | |
| 2 | * Licensed to the Apache Software Foundation (ASF) under one or more | |
| 3 | * contributor license agreements. See the NOTICE file distributed with | |
| 4 | * this work for additional information regarding copyright ownership. | |
| 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 | |
| 6 | * (the "License"); you may not use this file except in compliance with | |
| 7 | * the License. You may obtain a copy of the License at | |
| 8 | * | |
| 9 | * http://www.apache.org/licenses/LICENSE-2.0 | |
| 10 | * | |
| 11 | * Unless required by applicable law or agreed to in writing, software | |
| 12 | * distributed under the License is distributed on an "AS IS" BASIS, | |
| 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| 14 | * See the License for the specific language governing permissions and | |
| 15 | * limitations under the License. | |
| 16 | */ | |
| 17 | package com.keenwrite.util; | |
| 18 | ||
| 19 | import java.util.function.BiConsumer; | |
| 20 | ||
| 21 | /** | |
| 22 | * A functional interface like {@link BiConsumer} that declares a {@link Throwable}. | |
| 23 | * | |
| 24 | * @param <T> Consumed type 1. | |
| 25 | * @param <U> Consumed type 2. | |
| 26 | * @param <E> The kind of thrown exception or error. | |
| 27 | */ | |
| 28 | @FunctionalInterface | |
| 29 | public interface FailableBiConsumer<T, U, E extends Throwable> { | |
| 30 | ||
| 31 | /** | |
| 32 | * Accepts the given arguments. | |
| 33 | * | |
| 34 | * @param t the first parameter for the consumable to accept | |
| 35 | * @param u the second parameter for the consumable to accept | |
| 36 | * @throws E Thrown when the consumer fails. | |
| 37 | */ | |
| 38 | void accept(T t, U u) throws E; | |
| 39 | } | |
| 1 | 40 |
| 1 | /* | |
| 2 | * Licensed to the Apache Software Foundation (ASF) under one or more | |
| 3 | * contributor license agreements. See the NOTICE file distributed with | |
| 4 | * this work for additional information regarding copyright ownership. | |
| 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 | |
| 6 | * (the "License"); you may not use this file except in compliance with | |
| 7 | * the License. You may obtain a copy of the License at | |
| 8 | * | |
| 9 | * http://www.apache.org/licenses/LICENSE-2.0 | |
| 10 | * | |
| 11 | * Unless required by applicable law or agreed to in writing, software | |
| 12 | * distributed under the License is distributed on an "AS IS" BASIS, | |
| 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| 14 | * See the License for the specific language governing permissions and | |
| 15 | * limitations under the License. | |
| 16 | */ | |
| 17 | package com.keenwrite.util; | |
| 18 | ||
| 19 | import java.lang.reflect.Array; | |
| 20 | import java.util.Arrays; | |
| 21 | import java.util.HashSet; | |
| 22 | import java.util.Set; | |
| 23 | ||
| 24 | import static java.lang.Character.isWhitespace; | |
| 25 | import static java.lang.String.format; | |
| 26 | ||
| 27 | /** | |
| 28 | * Java doesn't allow adding behaviour to its {@link String} class, so these | |
| 29 | * functions have no alternative home. They are duplicated here to eliminate | |
| 30 | * the dependency on an Apache library. Extracting the methods that only | |
| 31 | * the application uses may have some small performance gains, as well, | |
| 32 | * because numerous if clauses have been removed and other code simplified. | |
| 33 | */ | |
| 34 | public class Strings { | |
| 35 | /** | |
| 36 | * The empty String {@code ""}. | |
| 37 | */ | |
| 38 | private static final String EMPTY = ""; | |
| 39 | ||
| 40 | /** | |
| 41 | * Abbreviates a String using ellipses. This will turn | |
| 42 | * "Now is the time for all good men" into "Now is the time for..." | |
| 43 | * | |
| 44 | * @param str the String to check, may be {@code null}. | |
| 45 | * @param width maximum length of result String, must be at least 4. | |
| 46 | * @return abbreviated String, {@code null} if {@code null} String input. | |
| 47 | * @throws IllegalArgumentException if the width is too small. | |
| 48 | */ | |
| 49 | public static String abbreviate( final String str, final int width ) { | |
| 50 | return abbreviate( str, "...", 0, width ); | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Abbreviates a String using another given String as replacement marker. | |
| 55 | * This will turn"Now is the time for all good men" into "Now is the time | |
| 56 | * for..." if "..." was defined as the replacement marker. | |
| 57 | * | |
| 58 | * @param str the String to check, may be {@code null}. | |
| 59 | * @param abbrMarker the String used as replacement marker. | |
| 60 | * @param width maximum length of result String, must be at least | |
| 61 | * {@code abbrMarker.length + 1}. | |
| 62 | * @return abbreviated String, {@code null} if {@code null} String input. | |
| 63 | * @throws IllegalArgumentException if the width is too small. | |
| 64 | */ | |
| 65 | public static String abbreviate( | |
| 66 | final String str, | |
| 67 | final String abbrMarker, | |
| 68 | final int width ) { | |
| 69 | return abbreviate( str, abbrMarker, 0, width ); | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Abbreviates a String using a given replacement marker. This will turn | |
| 74 | * "Now is the time for all good men" into "...is the time for..." if "..." | |
| 75 | * was defined as the replacement marker. | |
| 76 | * | |
| 77 | * @param str the String to check, may be {@code null}. | |
| 78 | * @param abbrMarker the String used as replacement marker. | |
| 79 | * @param offset left edge of source String. | |
| 80 | * @param width maximum length of result String, must be at least 4. | |
| 81 | * @return abbreviated String, {@code null} if {@code null} String input. | |
| 82 | * @throws IllegalArgumentException if the width is too small. | |
| 83 | */ | |
| 84 | public static String abbreviate( | |
| 85 | final String str, | |
| 86 | final String abbrMarker, | |
| 87 | int offset, | |
| 88 | final int width ) { | |
| 89 | if( !isEmpty( str ) && EMPTY.equals( abbrMarker ) && width > 0 ) { | |
| 90 | return substring( str, width ); | |
| 91 | } | |
| 92 | ||
| 93 | if( isAnyEmpty( str, abbrMarker ) ) { | |
| 94 | return str; | |
| 95 | } | |
| 96 | ||
| 97 | final int abbrMarkerLen = abbrMarker.length(); | |
| 98 | final int minAbbrWidth = abbrMarkerLen + 1; | |
| 99 | final int minAbbrWidthOffset = abbrMarkerLen + abbrMarkerLen + 1; | |
| 100 | ||
| 101 | if( width < minAbbrWidth ) { | |
| 102 | final String msg = format( "Min abbreviation width: %d", minAbbrWidth ); | |
| 103 | throw new IllegalArgumentException( msg ); | |
| 104 | } | |
| 105 | ||
| 106 | final int strLen = str.length(); | |
| 107 | ||
| 108 | if( strLen <= width ) { | |
| 109 | return str; | |
| 110 | } | |
| 111 | ||
| 112 | if( offset > strLen ) { | |
| 113 | offset = strLen; | |
| 114 | } | |
| 115 | ||
| 116 | if( strLen - offset < width - abbrMarkerLen ) { | |
| 117 | offset = strLen - (width - abbrMarkerLen); | |
| 118 | } | |
| 119 | ||
| 120 | if( offset <= abbrMarkerLen + 1 ) { | |
| 121 | return str.substring( 0, width - abbrMarkerLen ) + abbrMarker; | |
| 122 | } | |
| 123 | ||
| 124 | if( width < minAbbrWidthOffset ) { | |
| 125 | final String msg = format( | |
| 126 | "Min abbreviation width with offset: %d", | |
| 127 | minAbbrWidthOffset | |
| 128 | ); | |
| 129 | throw new IllegalArgumentException( msg ); | |
| 130 | } | |
| 131 | ||
| 132 | if( offset + width - abbrMarkerLen < strLen ) { | |
| 133 | return abbrMarker + abbreviate( | |
| 134 | str.substring( offset ), | |
| 135 | abbrMarker, | |
| 136 | width - abbrMarkerLen | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 140 | return abbrMarker + str.substring( strLen - (width - abbrMarkerLen) ); | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Strips whitespace characters from the end of a String. | |
| 145 | * | |
| 146 | * <p>A {@code null} input String returns {@code null}. | |
| 147 | * An empty string ("") input returns the empty string.</p> | |
| 148 | * | |
| 149 | * @param str the String to remove characters from, may be {@code null}. | |
| 150 | * @return the stripped String, {@code null} if {@code null} input. | |
| 151 | */ | |
| 152 | public static String trimEnd( final String str ) { | |
| 153 | int end = length( str ); | |
| 154 | ||
| 155 | if( end == 0 ) { | |
| 156 | return str; | |
| 157 | } | |
| 158 | ||
| 159 | while( end != 0 && isWhitespace( str.charAt( end - 1 ) ) ) { | |
| 160 | end--; | |
| 161 | } | |
| 162 | ||
| 163 | return str.substring( 0, end ); | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Strips whitespace characters from the start of a String. | |
| 168 | * | |
| 169 | * <p>A {@code null} input returns {@code null}. | |
| 170 | * An empty string ("") input returns the empty string.</p> | |
| 171 | * | |
| 172 | * @param str the String to remove characters from, may be {@code null}. | |
| 173 | * @return the stripped String, {@code null} if {@code null} input. | |
| 174 | */ | |
| 175 | public static String trimStart( final String str ) { | |
| 176 | final int strLen = length( str ); | |
| 177 | ||
| 178 | if( strLen == 0 ) { | |
| 179 | return str; | |
| 180 | } | |
| 181 | ||
| 182 | int start = 0; | |
| 183 | ||
| 184 | while( start != strLen && isWhitespace( str.charAt( start ) ) ) { | |
| 185 | start++; | |
| 186 | } | |
| 187 | ||
| 188 | return str.substring( start ); | |
| 189 | } | |
| 190 | ||
| 191 | /** | |
| 192 | * Replaces all occurrences of Strings within another String. | |
| 193 | * | |
| 194 | * @param text the haystack, no-op if {@code null}. | |
| 195 | * @param searchList the needles, no-op if {@code null}. | |
| 196 | * @param replacementList the new needles, no-op if {@code null}. | |
| 197 | * @return the text with any replacements processed, {@code null} if | |
| 198 | * {@code null} String input. | |
| 199 | * @throws IllegalArgumentException if the lengths of the arrays are not | |
| 200 | * the same ({@code null} is ok, and/or | |
| 201 | * size 0). | |
| 202 | */ | |
| 203 | public static String replaceEach( final String text, | |
| 204 | final String[] searchList, | |
| 205 | final String[] replacementList ) { | |
| 206 | return replaceEach( text, searchList, replacementList, 0 ); | |
| 207 | } | |
| 208 | ||
| 209 | /** | |
| 210 | * Replace all occurrences of Strings within another String. | |
| 211 | * | |
| 212 | * @param text the haystack, no-op if {@code null}. | |
| 213 | * @param searchList the needles, no-op if {@code null}. | |
| 214 | * @param replacementList the new needles, no-op if {@code null}. | |
| 215 | * @param timeToLive if less than 0 then there is a circular reference | |
| 216 | * and endless loop | |
| 217 | * @return the text with any replacements processed, {@code null} if | |
| 218 | * {@code null} String input. | |
| 219 | * @throws IllegalStateException if the search is repeating and there is | |
| 220 | * an endless loop due to outputs of one | |
| 221 | * being inputs to another | |
| 222 | * @throws IllegalArgumentException if the lengths of the arrays are not | |
| 223 | * the same ({@code null} is ok, and/or | |
| 224 | * size 0) | |
| 225 | */ | |
| 226 | private static String replaceEach( | |
| 227 | final String text, | |
| 228 | final String[] searchList, | |
| 229 | final String[] replacementList, | |
| 230 | final int timeToLive | |
| 231 | ) { | |
| 232 | // If in a recursive call, this shouldn't be less than zero. | |
| 233 | if( timeToLive < 0 ) { | |
| 234 | final Set<String> searchSet = | |
| 235 | new HashSet<>( Arrays.asList( searchList ) ); | |
| 236 | final Set<String> replacementSet = new HashSet<>( Arrays.asList( | |
| 237 | replacementList ) ); | |
| 238 | searchSet.retainAll( replacementSet ); | |
| 239 | if( !searchSet.isEmpty() ) { | |
| 240 | throw new IllegalStateException( | |
| 241 | "Aborting to protect against StackOverflowError - " + | |
| 242 | "output of one loop is the input of another" ); | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | if( isEmpty( text ) || | |
| 247 | isEmpty( searchList ) || | |
| 248 | isEmpty( replacementList ) || | |
| 249 | isNotEmpty( searchList ) && | |
| 250 | timeToLive == -1 ) { | |
| 251 | return text; | |
| 252 | } | |
| 253 | ||
| 254 | final int searchLength = searchList.length; | |
| 255 | final int replacementLength = replacementList.length; | |
| 256 | ||
| 257 | // make sure lengths are ok, these need to be equal | |
| 258 | if( searchLength != replacementLength ) { | |
| 259 | final String msg = format( | |
| 260 | "Search and Replace array lengths don't match: %d vs %d", | |
| 261 | searchLength, | |
| 262 | replacementLength | |
| 263 | ); | |
| 264 | throw new IllegalArgumentException( msg ); | |
| 265 | } | |
| 266 | ||
| 267 | // keep track of which still have matches | |
| 268 | final boolean[] noMoreMatchesForReplIndex = new boolean[ searchLength ]; | |
| 269 | ||
| 270 | // index on index that the match was found | |
| 271 | int textIndex = -1; | |
| 272 | int replaceIndex = -1; | |
| 273 | int tempIndex; | |
| 274 | ||
| 275 | // index of replace array that will replace the search string found | |
| 276 | // NOTE: logic duplicated below START | |
| 277 | for( int i = 0; i < searchLength; i++ ) { | |
| 278 | if( noMoreMatchesForReplIndex[ i ] || isEmpty( searchList[ i ] ) || replacementList[ i ] == null ) { | |
| 279 | continue; | |
| 280 | } | |
| 281 | tempIndex = text.indexOf( searchList[ i ] ); | |
| 282 | ||
| 283 | // see if we need to keep searching for this | |
| 284 | if( tempIndex == -1 ) { | |
| 285 | noMoreMatchesForReplIndex[ i ] = true; | |
| 286 | } | |
| 287 | else if( textIndex == -1 || tempIndex < textIndex ) { | |
| 288 | textIndex = tempIndex; | |
| 289 | replaceIndex = i; | |
| 290 | } | |
| 291 | } | |
| 292 | // NOTE: logic mostly below END | |
| 293 | ||
| 294 | // no search strings found, we are done | |
| 295 | if( textIndex == -1 ) { | |
| 296 | return text; | |
| 297 | } | |
| 298 | ||
| 299 | int start = 0; | |
| 300 | ||
| 301 | // Guess the result buffer size, to prevent doubling capacity. | |
| 302 | final StringBuilder buf = createStringBuilder( | |
| 303 | text, searchList, replacementList | |
| 304 | ); | |
| 305 | ||
| 306 | while( textIndex != -1 ) { | |
| 307 | for( int i = start; i < textIndex; i++ ) { | |
| 308 | buf.append( text.charAt( i ) ); | |
| 309 | } | |
| 310 | ||
| 311 | buf.append( replacementList[ replaceIndex ] ); | |
| 312 | ||
| 313 | start = textIndex + searchList[ replaceIndex ].length(); | |
| 314 | ||
| 315 | textIndex = -1; | |
| 316 | replaceIndex = -1; | |
| 317 | ||
| 318 | // find the next earliest match | |
| 319 | // NOTE: logic mostly duplicated above START | |
| 320 | for( int i = 0; i < searchLength; i++ ) { | |
| 321 | if( noMoreMatchesForReplIndex[ i ] || isEmpty( searchList[ i ] ) || replacementList[ i ] == null ) { | |
| 322 | continue; | |
| 323 | } | |
| 324 | tempIndex = text.indexOf( searchList[ i ], start ); | |
| 325 | ||
| 326 | // see if we need to keep searching for this | |
| 327 | if( tempIndex == -1 ) { | |
| 328 | noMoreMatchesForReplIndex[ i ] = true; | |
| 329 | } | |
| 330 | else if( textIndex == -1 || tempIndex < textIndex ) { | |
| 331 | textIndex = tempIndex; | |
| 332 | replaceIndex = i; | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | // NOTE: logic duplicated above END | |
| 337 | } | |
| 338 | ||
| 339 | final int textLength = text.length(); | |
| 340 | for( int i = start; i < textLength; i++ ) { | |
| 341 | buf.append( text.charAt( i ) ); | |
| 342 | } | |
| 343 | ||
| 344 | return replaceEach( | |
| 345 | buf.toString(), | |
| 346 | searchList, | |
| 347 | replacementList, | |
| 348 | timeToLive - 1 | |
| 349 | ); | |
| 350 | } | |
| 351 | ||
| 352 | private static StringBuilder createStringBuilder( | |
| 353 | final String text, | |
| 354 | final String[] searchList, | |
| 355 | final String[] replacementList ) { | |
| 356 | int increase = 0; | |
| 357 | ||
| 358 | // count the replacement text elements that are larger than their | |
| 359 | // corresponding text being replaced | |
| 360 | for( int i = 0; i < searchList.length; i++ ) { | |
| 361 | if( searchList[ i ] == null || replacementList[ i ] == null ) { | |
| 362 | continue; | |
| 363 | } | |
| 364 | final int greater = | |
| 365 | replacementList[ i ].length() - searchList[ i ].length(); | |
| 366 | if( greater > 0 ) { | |
| 367 | increase += 3 * greater; // assume 3 matches | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | // have upper-bound at 20% increase, then let Java take over | |
| 372 | increase = Math.min( increase, text.length() / 5 ); | |
| 373 | ||
| 374 | return new StringBuilder( text.length() + increase ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Gets a {@link CharSequence} length or {@code 0} if the | |
| 379 | * {@link CharSequence} is {@code null}. | |
| 380 | * | |
| 381 | * @param cs a {@link CharSequence} or {@code null}. | |
| 382 | * @return {@link CharSequence} length or {@code 0} if the | |
| 383 | * {@link CharSequence} is {@code null}. | |
| 384 | */ | |
| 385 | private static int length( final CharSequence cs ) { | |
| 386 | return cs == null ? 0 : cs.length(); | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Checks if a {@link CharSequence} is empty ("") or {@code null}. | |
| 391 | * | |
| 392 | * @param cs the {@link CharSequence} to check, may be {@code null}. | |
| 393 | * @return {@code true} if the {@link CharSequence} is empty or {@code null}. | |
| 394 | */ | |
| 395 | public static boolean isEmpty( final CharSequence cs ) { | |
| 396 | return cs == null || cs.isEmpty(); | |
| 397 | } | |
| 398 | ||
| 399 | private static boolean isEmpty( final Object[] array ) { | |
| 400 | return array == null || Array.getLength( array ) == 0; | |
| 401 | } | |
| 402 | ||
| 403 | private static boolean isNotEmpty( final Object[] array ) { | |
| 404 | return array != null && Array.getLength( array ) > 0; | |
| 405 | } | |
| 406 | ||
| 407 | private static boolean isAnyEmpty( final CharSequence... css ) { | |
| 408 | if( isNotEmpty( css ) ) { | |
| 409 | for( final CharSequence cs : css ) { | |
| 410 | if( isEmpty( cs ) ) { | |
| 411 | return true; | |
| 412 | } | |
| 413 | } | |
| 414 | } | |
| 415 | ||
| 416 | return false; | |
| 417 | } | |
| 418 | ||
| 419 | /** | |
| 420 | * Gets a substring from the specified String avoiding exceptions. | |
| 421 | * | |
| 422 | * <p>A negative start position can be used to start/end {@code n} | |
| 423 | * characters from the end of the String.</p> | |
| 424 | * | |
| 425 | * <p>The returned substring starts with the character in the {@code start} | |
| 426 | * position and ends before the {@code end} position. All position counting | |
| 427 | * is zero-based -- i.e., to start at the beginning of the string use | |
| 428 | * {@code start = 0}. Negative start and end positions can be used to | |
| 429 | * specify offsets relative to the end of the String.</p> | |
| 430 | * | |
| 431 | * <p>If {@code start} is not strictly to the left of {@code end}, "" | |
| 432 | * is returned.</p> | |
| 433 | * | |
| 434 | * @param str the String to get the substring from, may be {@code null}. | |
| 435 | * @param end the position to end at (exclusive), negative means | |
| 436 | * count back from the end of the String by this many characters | |
| 437 | * @return substring from start position to end position, {@code null} if | |
| 438 | * {@code null} String input | |
| 439 | */ | |
| 440 | private static String substring( final String str, int end ) { | |
| 441 | if( str == null ) { | |
| 442 | return null; | |
| 443 | } | |
| 444 | ||
| 445 | final int len = str.length(); | |
| 446 | ||
| 447 | if( end < 0 ) { | |
| 448 | end = len + end; | |
| 449 | } | |
| 450 | ||
| 451 | if( end > len ) { | |
| 452 | end = len; | |
| 453 | } | |
| 454 | ||
| 455 | final int start = 0; | |
| 456 | ||
| 457 | if( start > end ) { | |
| 458 | return EMPTY; | |
| 459 | } | |
| 460 | ||
| 461 | return str.substring( start, end ); | |
| 462 | } | |
| 463 | ||
| 464 | public static boolean validate( final String s ) { | |
| 465 | assert s != null; | |
| 466 | assert !s.isBlank(); | |
| 467 | ||
| 468 | return true; | |
| 469 | } | |
| 470 | ||
| 471 | public static String sanitize( final String s ) { | |
| 472 | return s == null ? "" : s; | |
| 473 | } | |
| 474 | } | |
| 1 | 475 |
| 1 | /* | |
| 2 | * Licensed to the Apache Software Foundation (ASF) under one or more | |
| 3 | * contributor license agreements. See the NOTICE file distributed with | |
| 4 | * this work for additional information regarding copyright ownership. | |
| 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 | |
| 6 | * (the "License"); you may not use this file except in compliance with | |
| 7 | * the License. You may obtain a copy of the License at | |
| 8 | * | |
| 9 | * http://www.apache.org/licenses/LICENSE-2.0 | |
| 10 | * | |
| 11 | * Unless required by applicable law or agreed to in writing, software | |
| 12 | * distributed under the License is distributed on an "AS IS" BASIS, | |
| 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| 14 | * See the License for the specific language governing permissions and | |
| 15 | * limitations under the License. | |
| 16 | */ | |
| 17 | package com.keenwrite.util; | |
| 18 | ||
| 19 | import java.util.Properties; | |
| 20 | ||
| 21 | import static com.keenwrite.util.Strings.isEmpty; | |
| 22 | ||
| 23 | /** | |
| 24 | * Helpers for {@code java.lang.System}. | |
| 25 | */ | |
| 26 | public class SystemUtils { | |
| 27 | ||
| 28 | // System property constants | |
| 29 | // ----------------------------------------------------------------------- | |
| 30 | // These MUST be declared first. Other constants depend on this. | |
| 31 | ||
| 32 | /** | |
| 33 | * The System property name {@value}. | |
| 34 | */ | |
| 35 | public static final String PROPERTY_OS_NAME = "os.name"; | |
| 36 | ||
| 37 | /** | |
| 38 | * Gets the current value from the system properties map. | |
| 39 | * <p> | |
| 40 | * Returns {@code null} if the property cannot be read due to a | |
| 41 | * {@link SecurityException}. | |
| 42 | * </p> | |
| 43 | * | |
| 44 | * @return the current value from the system properties map. | |
| 45 | */ | |
| 46 | @SuppressWarnings( "ConstantValue" ) | |
| 47 | private static String getOsName() { | |
| 48 | assert PROPERTY_OS_NAME != null; | |
| 49 | assert !PROPERTY_OS_NAME.isBlank(); | |
| 50 | ||
| 51 | try { | |
| 52 | final String value = System.getProperty( PROPERTY_OS_NAME ); | |
| 53 | ||
| 54 | return isEmpty( value ) ? "" : value; | |
| 55 | } catch( final SecurityException ignore ) {} | |
| 56 | ||
| 57 | return ""; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * The Operating System name, derived from Java's system properties. | |
| 62 | * | |
| 63 | * <p> | |
| 64 | * Defaults to empty if the runtime does not have security access to | |
| 65 | * read this property or the property does not exist. | |
| 66 | * </p> | |
| 67 | * <p> | |
| 68 | * This value is initialized when the class is loaded. If | |
| 69 | * {@link System#setProperty(String, String)} or | |
| 70 | * {@link System#setProperties(Properties)} is called after this | |
| 71 | * class is loaded, the value will be out of sync with that System property. | |
| 72 | * </p> | |
| 73 | */ | |
| 74 | public static final String OS_NAME = getOsName(); | |
| 75 | ||
| 76 | /** | |
| 77 | * Is {@code true} if this is AIX. | |
| 78 | * | |
| 79 | * <p> | |
| 80 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 81 | * </p> | |
| 82 | */ | |
| 83 | public static final boolean IS_OS_AIX = osNameMatches( "AIX" ); | |
| 84 | ||
| 85 | /** | |
| 86 | * Is {@code true} if this is HP-UX. | |
| 87 | * | |
| 88 | * <p> | |
| 89 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 90 | * </p> | |
| 91 | */ | |
| 92 | public static final boolean IS_OS_HP_UX = osNameMatches( "HP-UX" ); | |
| 93 | ||
| 94 | /** | |
| 95 | * Is {@code true} if this is Irix. | |
| 96 | * | |
| 97 | * <p> | |
| 98 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 99 | * </p> | |
| 100 | */ | |
| 101 | public static final boolean IS_OS_IRIX = osNameMatches( "Irix" ); | |
| 102 | ||
| 103 | /** | |
| 104 | * Is {@code true} if this is Linux. | |
| 105 | * | |
| 106 | * <p> | |
| 107 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 108 | * </p> | |
| 109 | */ | |
| 110 | public static final boolean IS_OS_LINUX = | |
| 111 | osNameMatches( "Linux" ) || | |
| 112 | osNameMatches( "LINUX" ); | |
| 113 | ||
| 114 | /** | |
| 115 | * Is {@code true} if this is Mac. | |
| 116 | * | |
| 117 | * <p> | |
| 118 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 119 | * </p> | |
| 120 | */ | |
| 121 | public static final boolean IS_OS_MAC = osNameMatches( "Mac" ); | |
| 122 | ||
| 123 | /** | |
| 124 | * Is {@code true} if this is Mac. | |
| 125 | * | |
| 126 | * <p> | |
| 127 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 128 | * </p> | |
| 129 | */ | |
| 130 | public static final boolean IS_OS_MAC_OSX = osNameMatches( "Mac OS X" ); | |
| 131 | ||
| 132 | /** | |
| 133 | * Is {@code true} if this is FreeBSD. | |
| 134 | * | |
| 135 | * <p> | |
| 136 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 137 | * </p> | |
| 138 | */ | |
| 139 | public static final boolean IS_OS_FREE_BSD = osNameMatches( "FreeBSD" ); | |
| 140 | ||
| 141 | /** | |
| 142 | * Is {@code true} if this is OpenBSD. | |
| 143 | * | |
| 144 | * <p> | |
| 145 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 146 | * </p> | |
| 147 | */ | |
| 148 | public static final boolean IS_OS_OPEN_BSD = osNameMatches( "OpenBSD" ); | |
| 149 | ||
| 150 | /** | |
| 151 | * Is {@code true} if this is NetBSD. | |
| 152 | * | |
| 153 | * <p> | |
| 154 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 155 | * </p> | |
| 156 | */ | |
| 157 | public static final boolean IS_OS_NET_BSD = osNameMatches( "NetBSD" ); | |
| 158 | ||
| 159 | /** | |
| 160 | * Is {@code true} if this is Solaris. | |
| 161 | * | |
| 162 | * <p> | |
| 163 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 164 | * </p> | |
| 165 | */ | |
| 166 | public static final boolean IS_OS_SOLARIS = osNameMatches( "Solaris" ); | |
| 167 | ||
| 168 | /** | |
| 169 | * Is {@code true} if this is SunOS. | |
| 170 | * | |
| 171 | * <p> | |
| 172 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 173 | * </p> | |
| 174 | */ | |
| 175 | public static final boolean IS_OS_SUN_OS = osNameMatches( "SunOS" ); | |
| 176 | ||
| 177 | /** | |
| 178 | * Is {@code true} if this is a UNIX like system, as in any of AIX, HP-UX, | |
| 179 | * Irix, Linux, MacOSX, Solaris or SUN OS. | |
| 180 | * | |
| 181 | * <p> | |
| 182 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 183 | * </p> | |
| 184 | */ | |
| 185 | public static final boolean IS_OS_UNIX = | |
| 186 | IS_OS_AIX || | |
| 187 | IS_OS_HP_UX || | |
| 188 | IS_OS_IRIX || | |
| 189 | IS_OS_LINUX || | |
| 190 | IS_OS_MAC_OSX || | |
| 191 | IS_OS_SOLARIS || | |
| 192 | IS_OS_SUN_OS || | |
| 193 | IS_OS_FREE_BSD || | |
| 194 | IS_OS_OPEN_BSD || | |
| 195 | IS_OS_NET_BSD; | |
| 196 | ||
| 197 | /** | |
| 198 | * The prefix String for all Windows OS. | |
| 199 | */ | |
| 200 | private static final String OS_NAME_WINDOWS_PREFIX = "Windows"; | |
| 201 | ||
| 202 | /** | |
| 203 | * Is {@code true} if this is Windows. | |
| 204 | * | |
| 205 | * <p> | |
| 206 | * The field will return {@code false} if {@code OS_NAME} is {@code null}. | |
| 207 | * </p> | |
| 208 | */ | |
| 209 | public static final boolean IS_OS_WINDOWS = | |
| 210 | osNameMatches( OS_NAME_WINDOWS_PREFIX ); | |
| 211 | ||
| 212 | /** | |
| 213 | * Decides if the operating system matches. | |
| 214 | * <p> | |
| 215 | * This method is package private instead of private to support unit test | |
| 216 | * invocation. | |
| 217 | * </p> | |
| 218 | * | |
| 219 | * @param prefix the prefix for the expected OS name | |
| 220 | * @return true if matches, or false if not or can't determine | |
| 221 | */ | |
| 222 | private static boolean osNameMatches( final String prefix ) { | |
| 223 | return OS_NAME.startsWith( prefix ); | |
| 224 | } | |
| 225 | } | |
| 1 | 226 |
| 36 | 36 | workspace.typeset.typography.quotes.desc=Export straight quotes and apostrophes as curled equivalents. |
| 37 | 37 | workspace.typeset.typography.quotes.title=Curl |
| 38 | ||
| 39 | workspace.r=R | |
| 40 | workspace.r.script=Startup Script | |
| 41 | workspace.r.script.desc=Script runs prior to executing R statements within the document. | |
| 42 | workspace.r.dir=Working Directory | |
| 43 | workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script. | |
| 44 | workspace.r.dir.title=Directory | |
| 45 | workspace.r.delimiter.began=Delimiter Prefix | |
| 46 | workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables. | |
| 47 | workspace.r.delimiter.began.title=Opening | |
| 48 | workspace.r.delimiter.ended=Delimiter Suffix | |
| 49 | workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables. | |
| 50 | workspace.r.delimiter.ended.title=Closing | |
| 51 | ||
| 52 | workspace.images=Images | |
| 53 | workspace.images.dir=Absolute Directory | |
| 54 | workspace.images.dir.desc=Path to search for local file system images. | |
| 55 | workspace.images.dir.title=Directory | |
| 56 | workspace.images.cache.desc=Path to store remotely retrieved images. | |
| 57 | workspace.images.cache.title=Directory | |
| 58 | workspace.images.order=Extensions | |
| 59 | workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | |
| 60 | workspace.images.order.title=Extensions | |
| 61 | workspace.images.resize=Resize | |
| 62 | workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically. | |
| 63 | workspace.images.resize.title=Resize | |
| 64 | workspace.images.server=Diagram Server | |
| 65 | workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io). | |
| 66 | workspace.images.server.title=Name | |
| 67 | ||
| 68 | workspace.definition=Variable | |
| 69 | workspace.definition.path=File name | |
| 70 | workspace.definition.path.desc=Absolute path to interpolated string variables. | |
| 71 | workspace.definition.path.title=Path | |
| 72 | workspace.definition.delimiter.began=Delimiter Prefix | |
| 73 | workspace.definition.delimiter.began.desc=Indicates when a variable name is starting. | |
| 74 | workspace.definition.delimiter.began.title=Opening | |
| 75 | workspace.definition.delimiter.ended=Delimiter Suffix | |
| 76 | workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending. | |
| 77 | workspace.definition.delimiter.ended.title=Closing | |
| 78 | ||
| 79 | workspace.ui.skin=Skins | |
| 80 | workspace.ui.skin.selection=Bundled | |
| 81 | workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light). | |
| 82 | workspace.ui.skin.selection.title=Name | |
| 83 | workspace.ui.skin.custom=Custom | |
| 84 | workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file. | |
| 85 | workspace.ui.skin.custom.title=Path | |
| 86 | ||
| 87 | workspace.ui.preview=Preview | |
| 88 | workspace.ui.preview.stylesheet=Stylesheet | |
| 89 | workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file. | |
| 90 | workspace.ui.preview.stylesheet.title=Path | |
| 91 | ||
| 92 | workspace.ui.font=Fonts | |
| 93 | workspace.ui.font.editor=Editor Font | |
| 94 | workspace.ui.font.editor.name=Name | |
| 95 | workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | |
| 96 | workspace.ui.font.editor.name.title=Family | |
| 97 | workspace.ui.font.editor.size=Size | |
| 98 | workspace.ui.font.editor.size.desc=Font size. | |
| 99 | workspace.ui.font.editor.size.title=Points | |
| 100 | workspace.ui.font.preview=Preview Font | |
| 101 | workspace.ui.font.preview.name=Name | |
| 102 | workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | |
| 103 | workspace.ui.font.preview.name.title=Family | |
| 104 | workspace.ui.font.preview.size=Size | |
| 105 | workspace.ui.font.preview.size.desc=Font size. | |
| 106 | workspace.ui.font.preview.size.title=Points | |
| 107 | workspace.ui.font.preview.mono.name=Name | |
| 108 | workspace.ui.font.preview.mono.name.desc=Monospace font name. | |
| 109 | workspace.ui.font.preview.mono.name.title=Family | |
| 110 | workspace.ui.font.preview.mono.size=Size | |
| 111 | workspace.ui.font.preview.mono.size.desc=Monospace font size. | |
| 112 | workspace.ui.font.preview.mono.size.title=Points | |
| 113 | workspace.ui.font.math=Math Font | |
| 114 | workspace.ui.font.math.size.title=Scale | |
| 115 | ||
| 116 | workspace.language=Language | |
| 117 | workspace.language.locale=Internationalization | |
| 118 | workspace.language.locale.desc=Language for application and HTML export. | |
| 119 | workspace.language.locale.title=Locale | |
| 120 | ||
| 121 | # ######################################################################## | |
| 122 | # Editor actions | |
| 123 | # ######################################################################## | |
| 124 | ||
| 125 | Editor.spelling.check.matches.none=No suggestions for ''{0}'' found. | |
| 126 | Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct. | |
| 127 | ||
| 128 | # ######################################################################## | |
| 129 | # Menu Bar | |
| 130 | # ######################################################################## | |
| 131 | ||
| 132 | Main.menu.file=_File | |
| 133 | Main.menu.edit=_Edit | |
| 134 | Main.menu.insert=_Insert | |
| 135 | Main.menu.format=Forma_t | |
| 136 | Main.menu.definition=_Variable | |
| 137 | Main.menu.view=Vie_w | |
| 138 | Main.menu.help=_Help | |
| 139 | ||
| 140 | # ######################################################################## | |
| 141 | # Detachable Tabs | |
| 142 | # ######################################################################## | |
| 143 | ||
| 144 | # {0} is the application title; {1} is a unique window ID. | |
| 145 | Detach.tab.title={0} - {1} | |
| 146 | ||
| 147 | # ######################################################################## | |
| 148 | # Status Bar | |
| 149 | # ######################################################################## | |
| 150 | ||
| 151 | Main.status.text.offset=offset | |
| 152 | Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2} | |
| 153 | Main.status.state.default=OK | |
| 154 | Main.status.export.success=Saved as ''{0}'' | |
| 155 | ||
| 156 | Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found | |
| 157 | Main.status.error.bootstrap.cache=Could not create cache directory ''{0}'' | |
| 158 | ||
| 159 | Main.status.error.parse=Evaluation error: {0} | |
| 160 | Main.status.error.def.blank=Move the caret to a word before inserting a variable | |
| 161 | Main.status.error.def.empty=Create a variable before inserting one | |
| 162 | Main.status.error.def.missing=No variable value found for ''{0}'' | |
| 163 | Main.status.error.r=Error with [{0}...]: {1} | |
| 164 | ||
| 165 | Main.status.error.file.missing=Not found: ''{0}'' | |
| 166 | Main.status.error.file.missing.near=Not found: ''{0}'' near line {1} | |
| 167 | Main.status.error.file.delete=Failed to delete ''{0}'' | |
| 168 | ||
| 169 | Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | |
| 170 | Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | |
| 171 | ||
| 172 | Main.status.error.undo=Cannot undo; beginning of undo history reached | |
| 173 | Main.status.error.redo=Cannot redo; end of redo history reached | |
| 174 | ||
| 175 | Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'') | |
| 176 | Main.status.error.theme.name=Cannot find theme name for ''{0}'' | |
| 177 | ||
| 178 | Main.status.image.request.init=Initializing HTTP request | |
| 179 | Main.status.image.request.fetch=Downloaded image ''{0}'' | |
| 180 | Main.status.image.request.success=Determined content type ''{0}'' | |
| 181 | Main.status.image.request.resolve=Resolved image path: ''{0}'' | |
| 182 | Main.status.image.request.error.media=No media type for ''{0}'' | |
| 183 | Main.status.image.request.error.cert=Could not accept certificate for ''{0}'' | |
| 184 | Main.status.image.request.error.create=Could not create image for preview document | |
| 185 | Main.status.image.request.error.resolve=Could not resolve image path: ''{0}'' | |
| 186 | ||
| 187 | Main.status.image.xhtml.image.download=Downloading ''{0}'' | |
| 188 | Main.status.image.xhtml.image.resolve=Qualify path for ''{0}'' | |
| 189 | Main.status.image.xhtml.image.found=Found image ''{0}'' | |
| 190 | Main.status.image.xhtml.image.missing=Missing image ''{0}'' | |
| 191 | Main.status.image.xhtml.image.saved=Saved image ''{0}'' | |
| 192 | Main.status.image.xhtml.image.failed=Cannot save image ''{0}'' | |
| 193 | ||
| 194 | Main.status.font.search.missing=No font name starting with ''{0}'' was found | |
| 195 | ||
| 196 | Main.status.export.concat=Concatenating ''{0}'' | |
| 197 | Main.status.export.concat.parent=No parent directory found for ''{0}'' | |
| 198 | Main.status.export.concat.extension=File name must have an extension ''{0}'' | |
| 199 | Main.status.export.concat.io=Could not read from ''{0}'' | |
| 200 | ||
| 201 | Main.status.typeset.create=Creating typesetter | |
| 202 | Main.status.typeset.xhtml=Export document as XHTML | |
| 203 | Main.status.typeset.began=Started typesetting ''{0}'' | |
| 204 | Main.status.typeset.failed=Could not generate PDF file | |
| 205 | Main.status.typeset.page=Typesetting page {0} of {1} (pass {2}) | |
| 206 | Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed) | |
| 207 | Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed) | |
| 208 | Main.status.typeset.setting=Set {0} to ''{1}'' | |
| 209 | ||
| 210 | Main.status.lexicon.loading=Loading lexicon: {0} words | |
| 211 | Main.status.lexicon.loaded=Loaded lexicon: {0} words | |
| 212 | ||
| 213 | # ######################################################################## | |
| 214 | # Search Bar | |
| 215 | # ######################################################################## | |
| 216 | ||
| 217 | Main.search.stop.tooltip=Close search bar | |
| 218 | Main.search.stop.icon=CLOSE | |
| 219 | Main.search.next.tooltip=Find next match | |
| 220 | Main.search.next.icon=CHEVRON_DOWN | |
| 221 | Main.search.prev.tooltip=Find previous match | |
| 222 | Main.search.prev.icon=CHEVRON_UP | |
| 223 | Main.search.find.tooltip=Search document for text | |
| 224 | Main.search.find.icon=SEARCH | |
| 225 | Main.search.match.none=No matches | |
| 226 | Main.search.match.some={0} of {1} matches | |
| 227 | ||
| 228 | # ######################################################################## | |
| 229 | # Definition Pane and its Tree View | |
| 230 | # ######################################################################## | |
| 231 | ||
| 232 | Definition.menu.add.default=Undefined | |
| 233 | ||
| 234 | # ######################################################################## | |
| 235 | # Variable Definitions Pane | |
| 236 | # ######################################################################## | |
| 237 | ||
| 238 | Pane.definition.node.root.title=Variables | |
| 239 | ||
| 240 | # ######################################################################## | |
| 241 | # HTML Preview Pane | |
| 242 | # ######################################################################## | |
| 243 | ||
| 244 | Pane.preview.title=Preview | |
| 245 | ||
| 246 | # ######################################################################## | |
| 247 | # Document Outline Pane | |
| 248 | # ######################################################################## | |
| 249 | ||
| 250 | Pane.outline.title=Outline | |
| 251 | ||
| 252 | # ######################################################################## | |
| 253 | # File Manager Pane | |
| 254 | # ######################################################################## | |
| 255 | ||
| 256 | Pane.files.title=Files | |
| 257 | ||
| 258 | # ######################################################################## | |
| 259 | # Document Outline Pane | |
| 260 | # ######################################################################## | |
| 261 | ||
| 262 | Pane.statistics.title=Statistics | |
| 263 | ||
| 264 | # ######################################################################## | |
| 265 | # Failure messages with respect to YAML files. | |
| 266 | # ######################################################################## | |
| 267 | ||
| 268 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 269 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 270 | yaml.error.missing=Empty variable value for key ''{0}''. | |
| 271 | yaml.error.tree.form=Unassigned variable near ''{0}''. | |
| 272 | ||
| 273 | # ######################################################################## | |
| 274 | # Text Resource | |
| 275 | # ######################################################################## | |
| 276 | ||
| 277 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 278 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 279 | ||
| 280 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 281 | TextResource.saveFailed.title=Save | |
| 282 | ||
| 283 | # ######################################################################## | |
| 284 | # File Open | |
| 285 | # ######################################################################## | |
| 286 | ||
| 287 | Dialog.file.choose.open.title=Open File | |
| 288 | Dialog.file.choose.save.title=Save File | |
| 289 | Dialog.file.choose.export.title=Export File | |
| 290 | Dialog.file.choose.import.title=Import File | |
| 291 | ||
| 292 | Dialog.file.choose.filter.title.source=Source Files | |
| 293 | Dialog.file.choose.filter.title.definition=Variable Files | |
| 294 | Dialog.file.choose.filter.title.xml=XML Files | |
| 295 | Dialog.file.choose.filter.title.all=All Files | |
| 296 | ||
| 297 | # ######################################################################## | |
| 298 | # Browse File | |
| 299 | # ######################################################################## | |
| 300 | ||
| 301 | BrowseFileButton.chooser.title=Open local file | |
| 302 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 303 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 304 | ||
| 305 | # ######################################################################## | |
| 306 | # Browse Directory | |
| 307 | # ######################################################################## | |
| 308 | ||
| 309 | BrowseDirectoryButton.chooser.title=Open local directory | |
| 310 | BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title} | |
| 311 | ||
| 312 | # ######################################################################## | |
| 313 | # Alert Dialog | |
| 314 | # ######################################################################## | |
| 315 | ||
| 316 | Alert.file.close.title=Close | |
| 317 | Alert.file.close.text=Save changes to {0}? | |
| 318 | ||
| 319 | # ######################################################################## | |
| 320 | # Typesetter Installation Wizard | |
| 321 | # ######################################################################## | |
| 322 | ||
| 323 | Wizard.typesetter.name=ConTeXt | |
| 324 | Wizard.typesetter.container.name=Podman | |
| 325 | Wizard.typesetter.container.version=4.8.2 | |
| 326 | Wizard.typesetter.container.checksum=250b12c24444005e09306eda38fa63c60cb1bdadf040f4e3f24f976e213cd462 | |
| 327 | Wizard.typesetter.container.image.name=typesetter | |
| 328 | Wizard.typesetter.container.image.version=3.1.0 | |
| 329 | Wizard.typesetter.container.image.tag=${Wizard.typesetter.container.image.name}:${Wizard.typesetter.container.image.version} | |
| 330 | Wizard.typesetter.container.image.url=https://repository.keenwrite.com/containers/${Wizard.typesetter.container.image.tag} | |
| 331 | Wizard.typesetter.themes.version=1.10.0 | |
| 332 | Wizard.typesetter.themes.checksum=38ce9c130cb8f527465baa3ca1e79c23ff92156c4fe9b842cc04fd80a7e10359 | |
| 333 | ||
| 334 | Wizard.container.install.command=Installing container using: ''{0}'' | |
| 335 | Wizard.container.install.await=Waiting for installer to finish | |
| 336 | Wizard.container.install.download.started=Download ''{0}'' started | |
| 337 | Wizard.container.install.download.running=Download in progress, please wait | |
| 338 | Wizard.container.process.enter=Running ''{0}'' ''{1}'' | |
| 339 | Wizard.container.process.exit=Process exit code (zero means success): {0} | |
| 340 | Wizard.container.executable.run.scan=''{0}'' is executable: {1} | |
| 341 | Wizard.container.executable.run.error=Cannot run container | |
| 342 | Wizard.container.executable.which=Cannot find container using search command | |
| 343 | Wizard.container.executable.path=Cannot find container using PATH variable | |
| 344 | Wizard.container.executable.registry=Cannot find container using registry | |
| 345 | ||
| 346 | # STEP 1: Introduction panel (all) | |
| 347 | Wizard.typesetter.all.1.install.title=Install typesetting system | |
| 348 | Wizard.typesetter.all.1.install.header=Install typesetting system | |
| 349 | Wizard.typesetter.all.1.install.about.container.link.lbl=${Wizard.typesetter.container.name} | |
| 350 | Wizard.typesetter.all.1.install.about.container.link.url=https://podman.io | |
| 351 | Wizard.typesetter.all.1.install.about.text.1=manages the container for the extensive | |
| 352 | Wizard.typesetter.all.1.install.about.typesetter.link.lbl=${Wizard.typesetter.name} | |
| 353 | Wizard.typesetter.all.1.install.about.typesetter.link.url=https://contextgarden.net | |
| 354 | Wizard.typesetter.all.1.install.about.text.2=\ | |
| 355 | typesetting software, which generates PDF files. This wizard\n\ | |
| 356 | will guide you through the installation process. After each\n\ | |
| 357 | step, you'll be prompted to click a button. Click Next to begin. | |
| 358 | ||
| 359 | # STEP 2: Install container manager (Unix) | |
| 360 | # Append steps to keep numbers stable; sorted programmatically. | |
| 361 | Wizard.typesetter.unix.2.install.container.header=Install ${Wizard.typesetter.container.name} for Linux / macOS / Unix | |
| 362 | # Copy button states | |
| 363 | Wizard.typesetter.unix.2.install.container.copy.began=Copy | |
| 364 | Wizard.typesetter.unix.2.install.container.copy.ended=Copied | |
| 365 | Wizard.typesetter.unix.2.install.container.os=Operating System | |
| 366 | Wizard.typesetter.unix.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}: | |
| 367 | Wizard.typesetter.unix.2.install.container.step.1=\t1. Select this computer's ${Wizard.typesetter.unix.2.install.container.os}. | |
| 368 | Wizard.typesetter.unix.2.install.container.step.2=\t2. Open a new terminal. | |
| 369 | Wizard.typesetter.unix.2.install.container.step.3=\t3. Run the commands provided below in the terminal. | |
| 370 | Wizard.typesetter.unix.2.install.container.step.4=\t4. Click Next to continue. | |
| 371 | Wizard.typesetter.unix.2.install.container.details.prefix=See | |
| 372 | Wizard.typesetter.unix.2.install.container.details.link.lbl=${Wizard.typesetter.container.name}'s instructions | |
| 373 | Wizard.typesetter.unix.2.install.container.details.link.url=https://podman.io/getting-started/installation | |
| 374 | Wizard.typesetter.unix.2.install.container.details.suffix=for more details. | |
| 375 | Wizard.typesetter.unix.2.install.container.command.distros=14 | |
| 376 | Wizard.typesetter.unix.2.install.container.command.os.name.01=Arch Linux & Manjaro Linux | |
| 377 | Wizard.typesetter.unix.2.install.container.command.os.text.01=sudo pacman -S podman | |
| 378 | Wizard.typesetter.unix.2.install.container.command.os.name.02=Alpine Linux | |
| 379 | Wizard.typesetter.unix.2.install.container.command.os.text.02=sudo apk add podman | |
| 380 | Wizard.typesetter.unix.2.install.container.command.os.name.03=CentOS | |
| 381 | Wizard.typesetter.unix.2.install.container.command.os.text.03=sudo yum -y install podman | |
| 382 | Wizard.typesetter.unix.2.install.container.command.os.name.04=Debian | |
| 383 | Wizard.typesetter.unix.2.install.container.command.os.text.04=sudo apt-get -y install podman | |
| 384 | Wizard.typesetter.unix.2.install.container.command.os.name.05=Fedora | |
| 385 | Wizard.typesetter.unix.2.install.container.command.os.text.05=sudo dnf -y install podman | |
| 386 | Wizard.typesetter.unix.2.install.container.command.os.name.06=Gentoo | |
| 387 | Wizard.typesetter.unix.2.install.container.command.os.text.06=sudo emerge app-containers/podman | |
| 388 | Wizard.typesetter.unix.2.install.container.command.os.name.07=OpenEmbedded | |
| 389 | Wizard.typesetter.unix.2.install.container.command.os.text.07=bitbake podman | |
| 390 | Wizard.typesetter.unix.2.install.container.command.os.name.08=openSUSE | |
| 391 | Wizard.typesetter.unix.2.install.container.command.os.text.08=sudo zypper install podman | |
| 392 | Wizard.typesetter.unix.2.install.container.command.os.name.09=RHEL7 | |
| 393 | Wizard.typesetter.unix.2.install.container.command.os.text.09=\ | |
| 394 | sudo subscription-manager repos \ | |
| 395 | --enable=rhel-7-server-extras-rpms\n\ | |
| 396 | sudo yum -y install podman | |
| 397 | Wizard.typesetter.unix.2.install.container.command.os.name.10=RHEL8 | |
| 398 | Wizard.typesetter.unix.2.install.container.command.os.text.10=\ | |
| 399 | sudo yum module enable -y container-tools:rhel8\n\ | |
| 400 | sudo yum module install -y container-tools:rhel8 | |
| 401 | Wizard.typesetter.unix.2.install.container.command.os.name.11=Ubuntu 20.10+ | |
| 402 | Wizard.typesetter.unix.2.install.container.command.os.text.11=\ | |
| 403 | sudo apt-get -y update\n\ | |
| 404 | sudo apt-get -y install podman | |
| 405 | Wizard.typesetter.unix.2.install.container.command.os.name.12=Linuxmint | |
| 406 | Wizard.typesetter.unix.2.install.container.command.os.text.12=${Wizard.typesetter.unix.2.install.container.command.os.text.11} | |
| 407 | Wizard.typesetter.unix.2.install.container.command.os.name.13=Linuxmint LMDE | |
| 408 | Wizard.typesetter.unix.2.install.container.command.os.text.13=${Wizard.typesetter.unix.2.install.container.command.os.text.04} | |
| 409 | Wizard.typesetter.unix.2.install.container.command.os.name.14=macOS | |
| 410 | Wizard.typesetter.unix.2.install.container.command.os.text.14=\ | |
| 411 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \n\ | |
| 412 | brew install podman | |
| 413 | ||
| 414 | # STEP 2 a: Download container manager (Windows) | |
| 415 | Wizard.typesetter.win.2.download.container.header=Download ${Wizard.typesetter.container.name} for Windows | |
| 416 | Wizard.typesetter.win.2.download.container.homepage.link.lbl=${Wizard.typesetter.container.name} | |
| 417 | Wizard.typesetter.win.2.download.container.homepage.link.url=https://podman.io | |
| 418 | Wizard.typesetter.win.2.download.container.download.link.lbl=repository | |
| 419 | Wizard.typesetter.win.2.download.container.download.link.url=https://github.com/containers/podman/releases/download/v${Wizard.typesetter.container.version}/podman-${Wizard.typesetter.container.version}-setup.exe | |
| 420 | Wizard.typesetter.win.2.download.container.paths=Downloading {0} into {1}. | |
| 421 | # suppress inspection "UnusedMessageFormatParameter" | |
| 422 | Wizard.typesetter.win.2.download.container.status.bytes=Downloaded {1} bytes (size unknown). | |
| 423 | Wizard.typesetter.win.2.download.container.status.progress=Downloaded {0} % of {1} bytes. | |
| 424 | Wizard.typesetter.win.2.download.container.status.checksum.ok=File {0} exists. Click Next to continue. | |
| 425 | Wizard.typesetter.win.2.download.container.status.checksum.no=Integrity check failed, {0} may be corrupt. | |
| 426 | Wizard.typesetter.win.2.download.container.status.success=Download successful. Click Next to continue. | |
| 427 | Wizard.typesetter.win.2.download.container.status.failure=Download failed. Check network then click Previous to try again. | |
| 428 | ||
| 429 | # STEP 2 b: Install container manager (Windows) | |
| 430 | Wizard.typesetter.win.2.install.container.header=Install ${Wizard.typesetter.container.name} for Windows | |
| 431 | Wizard.typesetter.win.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}: | |
| 432 | Wizard.typesetter.win.2.install.container.step.1=\t1. Open the task bar. | |
| 433 | Wizard.typesetter.win.2.install.container.step.2=\t2. Click the shield icon to grant permissions. | |
| 434 | Wizard.typesetter.win.2.install.container.step.3=\t3. Click Yes in the User Account Control dialog to install. | |
| 435 | Wizard.typesetter.win.2.install.container.status.running=Installing ... | |
| 436 | Wizard.typesetter.win.2.install.container.status.success=Installation successful.\nClick Next to continue. | |
| 437 | Wizard.typesetter.win.2.install.container.status.failure=Installation failed with exit code {0}. | |
| 438 | Wizard.typesetter.win.2.install.container.status.unknown=Could not determine installer file type: {0} | |
| 439 | ||
| 440 | # STEP 2: Install container manager (Universal, undetected operating system) | |
| 441 | Wizard.typesetter.all.2.install.container.header=Install ${Wizard.typesetter.container.name} | |
| 442 | Wizard.typesetter.all.2.install.container.homepage.lbl=${Wizard.typesetter.container.name} | |
| 443 | Wizard.typesetter.all.2.install.container.homepage.url=https://podman.io | |
| 444 | ||
| 445 | # STEP 3: Initialize container manager (all except Linux) | |
| 446 | Wizard.typesetter.all.3.install.container.header=Initialize ${Wizard.typesetter.container.name} | |
| 447 | Wizard.typesetter.all.3.install.container.correct=${Wizard.typesetter.container.name} initialized.\nClick Next to continue. | |
| 448 | Wizard.typesetter.all.3.install.container.missing=Install ${Wizard.typesetter.container.name} before continuing. | |
| 449 | ||
| 450 | # STEP 4: Install typesetter container image (all) | |
| 451 | Wizard.typesetter.all.4.download.image.header=Download ${Wizard.typesetter.name} image | |
| 452 | Wizard.typesetter.all.4.download.image.correct=Download successful.\nClick Next to continue. | |
| 453 | Wizard.typesetter.all.4.download.image.missing=Install ${Wizard.typesetter.container.name} before continuing. | |
| 454 | ||
| 455 | # STEP 5: Download typesetter themes (all) | |
| 456 | Wizard.typesetter.all.5.download.themes.header=Download ${Wizard.typesetter.name} themes | |
| 457 | Wizard.typesetter.all.5.download.themes.download.link.lbl=repository | |
| 458 | Wizard.typesetter.all.5.download.themes.download.link.url=https://gitlab.com/DaveJarvis/keenwrite-themes/-/releases/${Wizard.typesetter.themes.version}/downloads/theme-pack.zip | |
| 459 | Wizard.typesetter.all.5.download.themes.paths=Downloading {0} into {1}. | |
| 460 | Wizard.typesetter.all.5.download.themes.status.bytes=Downloaded {0} bytes (size unknown). | |
| 461 | Wizard.typesetter.all.5.download.themes.status.progress=Downloaded {0} % of {1} bytes. | |
| 462 | Wizard.typesetter.all.5.download.themes.status.checksum.ok=File {0} exists. Click Finish to continue. | |
| 463 | Wizard.typesetter.all.5.download.themes.status.checksum.no=Integrity check failed, {0} may be corrupt. | |
| 464 | Wizard.typesetter.all.5.download.themes.status.success=Download successful. Click Finish to continue. | |
| 465 | Wizard.typesetter.all.5.download.themes.status.failure=Download failed. Check network then click Previous to try again. | |
| 466 | ||
| 467 | # ######################################################################## | |
| 468 | # Image Dialog | |
| 469 | # ######################################################################## | |
| 470 | ||
| 471 | Dialog.image.title=Image | |
| 472 | Dialog.image.chooser.imagesFilter=Images | |
| 473 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 474 | Dialog.image.textLabel.text=Alternate Text\: | |
| 475 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 476 | Dialog.image.urlLabel.text=Image URL\: | |
| 477 | ||
| 478 | # ######################################################################## | |
| 479 | # Hyperlink Dialog | |
| 480 | # ######################################################################## | |
| 481 | ||
| 482 | Dialog.link.title=Link | |
| 483 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 484 | Dialog.link.textLabel.text=Link Text\: | |
| 485 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 486 | Dialog.link.urlLabel.text=Link URL\: | |
| 487 | ||
| 488 | # ######################################################################## | |
| 489 | # Typesetting Settings Dialog | |
| 490 | # ######################################################################## | |
| 491 | ||
| 492 | Dialog.typesetting.settings.title=Typesetting export settings | |
| 493 | Dialog.typesetting.settings.header.single=Export current document | |
| 494 | Dialog.typesetting.settings.theme=Theme | |
| 495 | Dialog.typesetting.settings.themes.missing=Install themes into {0}. | |
| 496 | ||
| 497 | Dialog.typesetting.settings.header.multiple=Export multiple documents | |
| 498 | Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-) | |
| 499 | ||
| 500 | # ######################################################################## | |
| 501 | # About Dialog | |
| 502 | # ######################################################################## | |
| 503 | ||
| 504 | Dialog.about.title=About {0} | |
| 505 | Dialog.about.header={0} | |
| 506 | Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | |
| 507 | ||
| 508 | # ######################################################################## | |
| 509 | # Application Actions | |
| 510 | # ######################################################################## | |
| 511 | ||
| 512 | Action.file.new.description=Create a new file | |
| 513 | Action.file.new.accelerator=Shortcut+N | |
| 514 | Action.file.new.icon=FILE_ALT | |
| 515 | Action.file.new.text=_New | |
| 516 | ||
| 517 | Action.file.open.description=Open a new file | |
| 518 | Action.file.open.accelerator=Shortcut+O | |
| 519 | Action.file.open.text=_Open... | |
| 520 | Action.file.open.icon=FOLDER_OPEN_ALT | |
| 521 | ||
| 522 | Action.file.close.description=Close the current document | |
| 523 | Action.file.close.accelerator=Shortcut+W | |
| 524 | Action.file.close.text=_Close | |
| 525 | ||
| 526 | Action.file.close_all.description=Close all open documents | |
| 527 | Action.file.close_all.accelerator=Ctrl+F4 | |
| 528 | Action.file.close_all.text=Close All | |
| 529 | ||
| 530 | Action.file.save.description=Save the document | |
| 531 | Action.file.save.accelerator=Shortcut+S | |
| 532 | Action.file.save.text=_Save | |
| 533 | Action.file.save.icon=FLOPPY_ALT | |
| 534 | ||
| 535 | Action.file.save_as.description=Rename the current document | |
| 536 | Action.file.save_as.text=Save _As | |
| 537 | ||
| 538 | Action.file.save_all.description=Save all open documents | |
| 539 | Action.file.save_all.accelerator=Shortcut+Shift+S | |
| 540 | Action.file.save_all.text=Save A_ll | |
| 541 | ||
| 542 | Action.file.export.pdf.description=Typeset the document | |
| 543 | Action.file.export.pdf.accelerator=Shortcut+P | |
| 544 | Action.file.export.pdf.text=_PDF | |
| 545 | Action.file.export.pdf.icon=FILE_PDF_ALT | |
| 546 | ||
| 547 | Action.file.export.pdf.dir.description=Typeset files in document directory | |
| 548 | Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P | |
| 549 | Action.file.export.pdf.dir.text=_Joined PDF | |
| 550 | Action.file.export.pdf.dir.icon=FILE_PDF_ALT | |
| 551 | ||
| 552 | Action.file.export.pdf.repeat.description=Repeat previous typesetting command | |
| 553 | Action.file.export.pdf.repeat.accelerator=Shortcut+Shift+E | |
| 554 | Action.file.export.pdf.repeat.text=_Repeat Export | |
| 555 | Action.file.export.pdf.repeat.icon=FILE_PDF_ALT | |
| 556 | ||
| 557 | Action.file.export.html.dir.description=Export files in document directory as HTML | |
| 558 | Action.file.export.html.dir.accelerator=Shortcut+Shift+H | |
| 559 | Action.file.export.html.dir.text=Joined _HTML | |
| 560 | Action.file.export.html.dir.icon=HTML5 | |
| 561 | ||
| 562 | Action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 563 | Action.file.export.text=_Export As | |
| 564 | Action.file.export.html_svg.text=HTML and S_VG | |
| 565 | ||
| 566 | Action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 567 | Action.file.export.html_tex.text=HTML and _TeX | |
| 568 | ||
| 569 | Action.file.export.xhtml_tex.description=Export as XHTML + TeX | |
| 570 | Action.file.export.xhtml_tex.text=_XHTML and TeX | |
| 571 | ||
| 572 | Action.file.export.markdown.description=Export the current document as Markdown | |
| 573 | Action.file.export.markdown.text=Markdown | |
| 574 | ||
| 575 | Action.file.exit.description=Quit the application | |
| 576 | Action.file.exit.text=E_xit | |
| 577 | ||
| 578 | ||
| 579 | Action.edit.undo.description=Undo the previous edit | |
| 580 | Action.edit.undo.accelerator=Shortcut+Z | |
| 581 | Action.edit.undo.text=_Undo | |
| 582 | Action.edit.undo.icon=UNDO | |
| 583 | ||
| 584 | Action.edit.redo.description=Redo the previous edit | |
| 585 | Action.edit.redo.accelerator=Shortcut+Y | |
| 586 | Action.edit.redo.text=_Redo | |
| 587 | Action.edit.redo.icon=REPEAT | |
| 588 | ||
| 589 | Action.edit.cut.description=Delete the selected text or line | |
| 590 | Action.edit.cut.accelerator=Shortcut+X | |
| 591 | Action.edit.cut.text=Cu_t | |
| 592 | Action.edit.cut.icon=CUT | |
| 593 | ||
| 594 | Action.edit.copy.description=Copy the selected text | |
| 595 | Action.edit.copy.accelerator=Shortcut+C | |
| 596 | Action.edit.copy.text=_Copy | |
| 597 | Action.edit.copy.icon=COPY | |
| 598 | ||
| 599 | Action.edit.paste.description=Paste from the clipboard | |
| 600 | Action.edit.paste.accelerator=Shortcut+V | |
| 601 | Action.edit.paste.text=_Paste | |
| 602 | Action.edit.paste.icon=PASTE | |
| 603 | ||
| 604 | Action.edit.select_all.description=Highlight the current document text | |
| 605 | Action.edit.select_all.accelerator=Shortcut+A | |
| 606 | Action.edit.select_all.text=Select _All | |
| 607 | ||
| 608 | Action.edit.find.description=Search for text in the document | |
| 609 | Action.edit.find.accelerator=Shortcut+F | |
| 610 | Action.edit.find.text=_Find | |
| 611 | Action.edit.find.icon=SEARCH | |
| 612 | ||
| 613 | Action.edit.find_next.description=Find next occurrence | |
| 614 | Action.edit.find_next.accelerator=F3 | |
| 615 | Action.edit.find_next.text=Find _Next | |
| 616 | ||
| 617 | Action.edit.find_prev.description=Find previous occurrence | |
| 618 | Action.edit.find_prev.accelerator=Shift+F3 | |
| 619 | Action.edit.find_prev.text=Find _Prev | |
| 620 | ||
| 621 | Action.edit.preferences.description=Edit user preferences | |
| 622 | Action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 623 | Action.edit.preferences.text=_Preferences | |
| 624 | ||
| 625 | ||
| 626 | Action.format.bold.description=Insert strong text | |
| 627 | Action.format.bold.accelerator=Shortcut+B | |
| 628 | Action.format.bold.text=_Bold | |
| 629 | Action.format.bold.icon=BOLD | |
| 630 | ||
| 631 | Action.format.italic.description=Insert text emphasis | |
| 632 | Action.format.italic.accelerator=Shortcut+I | |
| 633 | Action.format.italic.text=_Italic | |
| 634 | Action.format.italic.icon=ITALIC | |
| 635 | ||
| 636 | Action.format.monospace.description=Insert monospace text | |
| 637 | Action.format.monospace.accelerator=Shortcut+` | |
| 638 | Action.format.monospace.text=_Monospace | |
| 639 | ||
| 640 | Action.format.superscript.description=Insert superscript text | |
| 641 | Action.format.superscript.accelerator=Shortcut+[ | |
| 642 | Action.format.superscript.text=Su_perscript | |
| 643 | Action.format.superscript.icon=SUPERSCRIPT | |
| 644 | ||
| 645 | Action.format.subscript.description=Insert subscript text | |
| 646 | Action.format.subscript.accelerator=Shortcut+] | |
| 647 | Action.format.subscript.text=Su_bscript | |
| 648 | Action.format.subscript.icon=SUBSCRIPT | |
| 649 | ||
| 650 | Action.format.strikethrough.description=Insert struck text | |
| 651 | Action.format.strikethrough.accelerator=Shortcut+T | |
| 652 | Action.format.strikethrough.text=Stri_kethrough | |
| 653 | Action.format.strikethrough.icon=STRIKETHROUGH | |
| 654 | ||
| 655 | ||
| 656 | Action.insert.blockquote.description=Insert blockquote | |
| 657 | Action.insert.blockquote.accelerator=Ctrl+Q | |
| 658 | Action.insert.blockquote.text=_Blockquote | |
| 659 | Action.insert.blockquote.icon=QUOTE_LEFT | |
| 660 | ||
| 661 | Action.insert.code.description=Insert inline code | |
| 662 | Action.insert.code.accelerator=Shortcut+K | |
| 663 | Action.insert.code.text=Inline _Code | |
| 664 | Action.insert.code.icon=CODE | |
| 665 | ||
| 666 | Action.insert.fenced_code_block.description=Insert code block | |
| 667 | Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 668 | Action.insert.fenced_code_block.text=_Fenced Code Block | |
| 669 | Action.insert.fenced_code_block.prompt.text=Enter code here | |
| 670 | Action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 671 | ||
| 672 | Action.insert.link.description=Insert hyperlink | |
| 673 | Action.insert.link.accelerator=Shortcut+L | |
| 674 | Action.insert.link.text=_Link... | |
| 675 | Action.insert.link.icon=LINK | |
| 676 | ||
| 677 | Action.insert.image.description=Insert image | |
| 678 | Action.insert.image.accelerator=Shortcut+G | |
| 679 | Action.insert.image.text=_Image... | |
| 680 | Action.insert.image.icon=PICTURE_ALT | |
| 681 | ||
| 682 | Action.insert.heading.description=Insert heading level | |
| 683 | Action.insert.heading.accelerator=Shortcut+ | |
| 684 | Action.insert.heading.icon=HEADER | |
| 685 | ||
| 686 | Action.insert.heading_1.description=${Action.insert.heading.description} 1 | |
| 687 | Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1 | |
| 688 | Action.insert.heading_1.text=Heading _1 | |
| 689 | Action.insert.heading_1.icon=${Action.insert.heading.icon} | |
| 690 | ||
| 691 | Action.insert.heading_2.description=${Action.insert.heading.description} 2 | |
| 692 | Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2 | |
| 693 | Action.insert.heading_2.text=Heading _2 | |
| 694 | Action.insert.heading_2.icon=${Action.insert.heading.icon} | |
| 695 | ||
| 696 | Action.insert.heading_3.description=${Action.insert.heading.description} 3 | |
| 697 | Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3 | |
| 698 | Action.insert.heading_3.text=Heading _3 | |
| 699 | Action.insert.heading_3.icon=${Action.insert.heading.icon} | |
| 700 | ||
| 701 | Action.insert.unordered_list.description=Insert bulleted list | |
| 702 | Action.insert.unordered_list.accelerator=Shortcut+U | |
| 703 | Action.insert.unordered_list.text=_Unordered List | |
| 704 | Action.insert.unordered_list.icon=LIST_UL | |
| 705 | ||
| 706 | Action.insert.ordered_list.description=Insert enumerated list | |
| 707 | Action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 708 | Action.insert.ordered_list.text=_Ordered List | |
| 709 | Action.insert.ordered_list.icon=LIST_OL | |
| 710 | ||
| 711 | Action.insert.horizontal_rule.description=Insert horizontal rule | |
| 712 | Action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 713 | Action.insert.horizontal_rule.text=_Horizontal Rule | |
| 714 | Action.insert.horizontal_rule.icon=LIST_OL | |
| 715 | ||
| 716 | ||
| 717 | Action.definition.create.description=Create a new variable | |
| 718 | Action.definition.create.text=_Create | |
| 719 | Action.definition.create.icon=TREE | |
| 720 | Action.definition.create.tooltip=Add new item (Insert) | |
| 721 | ||
| 722 | Action.definition.rename.description=Rename the selected variable | |
| 723 | Action.definition.rename.text=_Rename | |
| 724 | Action.definition.rename.icon=EDIT | |
| 725 | Action.definition.rename.tooltip=Rename selected item (F2) | |
| 726 | ||
| 727 | Action.definition.delete.description=Delete the selected variables | |
| 728 | Action.definition.delete.text=De_lete | |
| 729 | Action.definition.delete.icon=TRASH | |
| 730 | Action.definition.delete.tooltip=Delete selected items (Delete) | |
| 731 | ||
| 732 | Action.definition.insert.description=Insert a variable | |
| 733 | Action.definition.insert.accelerator=Ctrl+Space | |
| 734 | Action.definition.insert.text=_Insert | |
| 735 | Action.definition.insert.icon=STAR | |
| 736 | ||
| 737 | ||
| 738 | Action.view.refresh.description=Clear all caches | |
| 739 | Action.view.refresh.accelerator=F5 | |
| 740 | Action.view.refresh.text=Refresh | |
| 741 | ||
| 742 | Action.view.preview.description=Open document preview | |
| 743 | Action.view.preview.accelerator=F6 | |
| 744 | Action.view.preview.text=Preview | |
| 745 | ||
| 746 | Action.view.outline.description=Open document outline | |
| 747 | Action.view.outline.accelerator=F7 | |
| 748 | Action.view.outline.text=Outline | |
| 749 | ||
| 750 | Action.view.statistics.description=Open document word counts | |
| 751 | Action.view.statistics.accelerator=F8 | |
| 752 | Action.view.statistics.text=Statistics | |
| 753 | ||
| 754 | Action.view.files.description=Open file manager | |
| 755 | Action.view.files.accelerator=Ctrl+F8 | |
| 756 | Action.view.files.text=Files | |
| 757 | ||
| 758 | Action.view.menubar.description=Toggle menu bar | |
| 759 | Action.view.menubar.accelerator=Ctrl+F9 | |
| 760 | Action.view.menubar.text=Menu bar | |
| 761 | ||
| 762 | Action.view.toolbar.description=Toggle toolbar | |
| 763 | Action.view.toolbar.accelerator=Ctrl+Shift+F9 | |
| 764 | Action.view.toolbar.text=Toolbar | |
| 765 | ||
| 766 | Action.view.statusbar.description=Toggle status bar | |
| 767 | Action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9 | |
| 38 | workspace.typeset.modes=Modes | |
| 39 | workspace.typeset.modes.enabled=Enabled | |
| 40 | workspace.typeset.modes.enabled.desc=Enable typesetting modes, separated by commas; values may use variables (e.g., '{{'document.category'}}'). | |
| 41 | workspace.typeset.modes.enabled.title=Enable | |
| 42 | ||
| 43 | workspace.r=R | |
| 44 | workspace.r.script=Startup Script | |
| 45 | workspace.r.script.desc=Script runs prior to executing R statements within the document. | |
| 46 | workspace.r.dir=Working Directory | |
| 47 | workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script. | |
| 48 | workspace.r.dir.title=Directory | |
| 49 | workspace.r.delimiter.began=Delimiter Prefix | |
| 50 | workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables. | |
| 51 | workspace.r.delimiter.began.title=Opening | |
| 52 | workspace.r.delimiter.ended=Delimiter Suffix | |
| 53 | workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables. | |
| 54 | workspace.r.delimiter.ended.title=Closing | |
| 55 | ||
| 56 | workspace.images=Images | |
| 57 | workspace.images.dir=Absolute Directory | |
| 58 | workspace.images.dir.desc=Path to search for local file system images. | |
| 59 | workspace.images.dir.title=Directory | |
| 60 | workspace.images.cache.desc=Path to store remotely retrieved images. | |
| 61 | workspace.images.cache.title=Directory | |
| 62 | workspace.images.order=Extensions | |
| 63 | workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | |
| 64 | workspace.images.order.title=Extensions | |
| 65 | workspace.images.resize=Resize | |
| 66 | workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically. | |
| 67 | workspace.images.resize.title=Resize | |
| 68 | workspace.images.server=Diagram Server | |
| 69 | workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io). | |
| 70 | workspace.images.server.title=Name | |
| 71 | ||
| 72 | workspace.definition=Variable | |
| 73 | workspace.definition.path=File name | |
| 74 | workspace.definition.path.desc=Absolute path to interpolated string variables. | |
| 75 | workspace.definition.path.title=Path | |
| 76 | workspace.definition.delimiter.began=Delimiter Prefix | |
| 77 | workspace.definition.delimiter.began.desc=Indicates when a variable name is starting. | |
| 78 | workspace.definition.delimiter.began.title=Opening | |
| 79 | workspace.definition.delimiter.ended=Delimiter Suffix | |
| 80 | workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending. | |
| 81 | workspace.definition.delimiter.ended.title=Closing | |
| 82 | ||
| 83 | workspace.ui.skin=Skins | |
| 84 | workspace.ui.skin.selection=Bundled | |
| 85 | workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light). | |
| 86 | workspace.ui.skin.selection.title=Name | |
| 87 | workspace.ui.skin.custom=Custom | |
| 88 | workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file. | |
| 89 | workspace.ui.skin.custom.title=Path | |
| 90 | ||
| 91 | workspace.ui.preview=Preview | |
| 92 | workspace.ui.preview.stylesheet=Stylesheet | |
| 93 | workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file. | |
| 94 | workspace.ui.preview.stylesheet.title=Path | |
| 95 | ||
| 96 | workspace.ui.font=Fonts | |
| 97 | workspace.ui.font.editor=Editor Font | |
| 98 | workspace.ui.font.editor.name=Name | |
| 99 | workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | |
| 100 | workspace.ui.font.editor.name.title=Family | |
| 101 | workspace.ui.font.editor.size=Size | |
| 102 | workspace.ui.font.editor.size.desc=Font size. | |
| 103 | workspace.ui.font.editor.size.title=Points | |
| 104 | workspace.ui.font.preview=Preview Font | |
| 105 | workspace.ui.font.preview.name=Name | |
| 106 | workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | |
| 107 | workspace.ui.font.preview.name.title=Family | |
| 108 | workspace.ui.font.preview.size=Size | |
| 109 | workspace.ui.font.preview.size.desc=Font size. | |
| 110 | workspace.ui.font.preview.size.title=Points | |
| 111 | workspace.ui.font.preview.mono.name=Name | |
| 112 | workspace.ui.font.preview.mono.name.desc=Monospace font name. | |
| 113 | workspace.ui.font.preview.mono.name.title=Family | |
| 114 | workspace.ui.font.preview.mono.size=Size | |
| 115 | workspace.ui.font.preview.mono.size.desc=Monospace font size. | |
| 116 | workspace.ui.font.preview.mono.size.title=Points | |
| 117 | workspace.ui.font.math=Math Font | |
| 118 | workspace.ui.font.math.size.title=Scale | |
| 119 | ||
| 120 | workspace.language=Language | |
| 121 | workspace.language.locale=Internationalization | |
| 122 | workspace.language.locale.desc=Language for application and HTML export. | |
| 123 | workspace.language.locale.title=Locale | |
| 124 | ||
| 125 | # ######################################################################## | |
| 126 | # Editor actions | |
| 127 | # ######################################################################## | |
| 128 | ||
| 129 | Editor.spelling.check.matches.none=No suggestions for ''{0}'' found. | |
| 130 | Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct. | |
| 131 | ||
| 132 | # ######################################################################## | |
| 133 | # Menu Bar | |
| 134 | # ######################################################################## | |
| 135 | ||
| 136 | Main.menu.file=_File | |
| 137 | Main.menu.edit=_Edit | |
| 138 | Main.menu.insert=_Insert | |
| 139 | Main.menu.format=Forma_t | |
| 140 | Main.menu.definition=_Variable | |
| 141 | Main.menu.view=Vie_w | |
| 142 | Main.menu.help=_Help | |
| 143 | ||
| 144 | # ######################################################################## | |
| 145 | # Detachable Tabs | |
| 146 | # ######################################################################## | |
| 147 | ||
| 148 | # {0} is the application title; {1} is a unique window ID. | |
| 149 | Detach.tab.title={0} - {1} | |
| 150 | ||
| 151 | # ######################################################################## | |
| 152 | # Status Bar | |
| 153 | # ######################################################################## | |
| 154 | ||
| 155 | Main.status.text.offset=offset | |
| 156 | Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2} | |
| 157 | Main.status.state.default=OK | |
| 158 | Main.status.export.success=Saved as ''{0}'' | |
| 159 | ||
| 160 | Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found | |
| 161 | Main.status.error.bootstrap.cache=Could not create cache directory ''{0}'' | |
| 162 | ||
| 163 | Main.status.error.parse=Evaluation error: {0} | |
| 164 | Main.status.error.def.blank=Move the caret to a word before inserting a variable | |
| 165 | Main.status.error.def.empty=Create a variable before inserting one | |
| 166 | Main.status.error.def.missing=No variable value found for ''{0}'' | |
| 167 | Main.status.error.r=Error with [{0}...]: {1} | |
| 168 | ||
| 169 | Main.status.error.file.missing=Not found: ''{0}'' | |
| 170 | Main.status.error.file.missing.near=Not found: ''{0}'' near line {1} | |
| 171 | Main.status.error.file.delete=Failed to delete ''{0}'' | |
| 172 | ||
| 173 | Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | |
| 174 | Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | |
| 175 | ||
| 176 | Main.status.error.undo=Cannot undo; beginning of undo history reached | |
| 177 | Main.status.error.redo=Cannot redo; end of redo history reached | |
| 178 | ||
| 179 | Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'') | |
| 180 | Main.status.error.theme.name=Cannot find theme name for ''{0}'' | |
| 181 | ||
| 182 | Main.status.image.request.init=Initializing HTTP request | |
| 183 | Main.status.image.request.fetch=Downloaded image ''{0}'' | |
| 184 | Main.status.image.request.success=Determined content type ''{0}'' | |
| 185 | Main.status.image.request.resolve=Resolved image path: ''{0}'' | |
| 186 | Main.status.image.request.error.media=No media type for ''{0}'' | |
| 187 | Main.status.image.request.error.cert=Could not accept certificate for ''{0}'' | |
| 188 | Main.status.image.request.error.create=Could not create image for preview document | |
| 189 | Main.status.image.request.error.resolve=Could not resolve image path: ''{0}'' | |
| 190 | ||
| 191 | Main.status.image.xhtml.image.download=Downloading ''{0}'' | |
| 192 | Main.status.image.xhtml.image.resolve=Qualify path for ''{0}'' | |
| 193 | Main.status.image.xhtml.image.found=Found image ''{0}'' | |
| 194 | Main.status.image.xhtml.image.missing=Missing image ''{0}'' | |
| 195 | Main.status.image.xhtml.image.saved=Saved image ''{0}'' | |
| 196 | Main.status.image.xhtml.image.failed=Cannot save image ''{0}'' | |
| 197 | ||
| 198 | Main.status.url.request.fetch=Download Markdown file from: ''{0}'' | |
| 199 | Main.status.url.request.success=Downloaded Markdown file ''{0}'' | |
| 200 | Main.status.url.request.failure=Could not save Markdown file to: ''{0}'' | |
| 201 | Main.status.url.request.exists=Download aborted; file exists: ''{0}'' | |
| 202 | # suppress inspection "UnusedMessageFormatParameter" | |
| 203 | Main.status.url.request.status.bytes=Downloaded {1} bytes (size unknown). | |
| 204 | Main.status.url.request.status.progress=Downloaded {0} % of {1} bytes. | |
| 205 | ||
| 206 | Main.status.font.search.missing=No font name starting with ''{0}'' was found | |
| 207 | ||
| 208 | Main.status.export.concat=Concatenating ''{0}'' | |
| 209 | Main.status.export.concat.parent=No parent directory found for ''{0}'' | |
| 210 | Main.status.export.concat.extension=File name must have an extension ''{0}'' | |
| 211 | Main.status.export.concat.io=Could not read from ''{0}'' | |
| 212 | ||
| 213 | Main.status.typeset.create=Creating typesetter | |
| 214 | Main.status.typeset.xhtml=Export document as XHTML | |
| 215 | Main.status.typeset.began=Started typesetting ''{0}'' | |
| 216 | Main.status.typeset.failed=Could not generate PDF file | |
| 217 | Main.status.typeset.page=Typesetting page {0} of {1} (pass {2}) | |
| 218 | Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed) | |
| 219 | Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed) | |
| 220 | Main.status.typeset.setting=Set {0} to ''{1}'' | |
| 221 | ||
| 222 | Main.status.lexicon.loading=Loading lexicon: {0} words | |
| 223 | Main.status.lexicon.loaded=Loaded lexicon: {0} words | |
| 224 | ||
| 225 | # ######################################################################## | |
| 226 | # Search Bar | |
| 227 | # ######################################################################## | |
| 228 | ||
| 229 | Main.search.stop.tooltip=Close search bar | |
| 230 | Main.search.stop.icon=CLOSE | |
| 231 | Main.search.next.tooltip=Find next match | |
| 232 | Main.search.next.icon=CHEVRON_DOWN | |
| 233 | Main.search.prev.tooltip=Find previous match | |
| 234 | Main.search.prev.icon=CHEVRON_UP | |
| 235 | Main.search.find.tooltip=Search document for text | |
| 236 | Main.search.find.icon=SEARCH | |
| 237 | Main.search.match.none=No matches | |
| 238 | Main.search.match.some={0} of {1} matches | |
| 239 | ||
| 240 | # ######################################################################## | |
| 241 | # Definition Pane and its Tree View | |
| 242 | # ######################################################################## | |
| 243 | ||
| 244 | Definition.menu.add.default=Undefined | |
| 245 | ||
| 246 | # ######################################################################## | |
| 247 | # Variable Definitions Pane | |
| 248 | # ######################################################################## | |
| 249 | ||
| 250 | Pane.definition.node.root.title=Variables | |
| 251 | ||
| 252 | # ######################################################################## | |
| 253 | # HTML Preview Pane | |
| 254 | # ######################################################################## | |
| 255 | ||
| 256 | Pane.preview.title=Preview | |
| 257 | ||
| 258 | # ######################################################################## | |
| 259 | # Document Outline Pane | |
| 260 | # ######################################################################## | |
| 261 | ||
| 262 | Pane.outline.title=Outline | |
| 263 | ||
| 264 | # ######################################################################## | |
| 265 | # File Manager Pane | |
| 266 | # ######################################################################## | |
| 267 | ||
| 268 | Pane.files.title=Files | |
| 269 | ||
| 270 | # ######################################################################## | |
| 271 | # Document Outline Pane | |
| 272 | # ######################################################################## | |
| 273 | ||
| 274 | Pane.statistics.title=Statistics | |
| 275 | ||
| 276 | # ######################################################################## | |
| 277 | # Failure messages with respect to YAML files. | |
| 278 | # ######################################################################## | |
| 279 | ||
| 280 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 281 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 282 | yaml.error.missing=Empty variable value for key ''{0}''. | |
| 283 | yaml.error.tree.form=Unassigned variable near ''{0}''. | |
| 284 | ||
| 285 | # ######################################################################## | |
| 286 | # Text Resource | |
| 287 | # ######################################################################## | |
| 288 | ||
| 289 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 290 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 291 | ||
| 292 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 293 | TextResource.saveFailed.title=Save | |
| 294 | ||
| 295 | # ######################################################################## | |
| 296 | # File Open | |
| 297 | # ######################################################################## | |
| 298 | ||
| 299 | Dialog.file.choose.open.title=Open File | |
| 300 | Dialog.file.choose.save.title=Save File | |
| 301 | Dialog.file.choose.export.title=Export File | |
| 302 | Dialog.file.choose.import.title=Import File | |
| 303 | ||
| 304 | Dialog.file.choose.filter.title.source=Source Files | |
| 305 | Dialog.file.choose.filter.title.definition=Variable Files | |
| 306 | Dialog.file.choose.filter.title.xml=XML Files | |
| 307 | Dialog.file.choose.filter.title.all=All Files | |
| 308 | ||
| 309 | # ######################################################################## | |
| 310 | # Browse Directory | |
| 311 | # ######################################################################## | |
| 312 | ||
| 313 | BrowseDirectoryButton.chooser.title=Open local directory | |
| 314 | BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title} | |
| 315 | ||
| 316 | # ######################################################################## | |
| 317 | # Alert Dialog | |
| 318 | # ######################################################################## | |
| 319 | ||
| 320 | Alert.file.close.title=Close | |
| 321 | Alert.file.close.text=Save changes to {0}? | |
| 322 | ||
| 323 | # ######################################################################## | |
| 324 | # Typesetter Installation Wizard | |
| 325 | # ######################################################################## | |
| 326 | ||
| 327 | Wizard.typesetter.name=ConTeXt | |
| 328 | Wizard.typesetter.container.name=Podman | |
| 329 | Wizard.typesetter.container.version=4.8.2 | |
| 330 | Wizard.typesetter.container.checksum=250b12c24444005e09306eda38fa63c60cb1bdadf040f4e3f24f976e213cd462 | |
| 331 | Wizard.typesetter.container.image.name=typesetter | |
| 332 | Wizard.typesetter.container.image.version=3.2.0 | |
| 333 | Wizard.typesetter.container.image.tag=${Wizard.typesetter.container.image.name}:${Wizard.typesetter.container.image.version} | |
| 334 | Wizard.typesetter.container.image.url=https://repository.keenwrite.com/containers/${Wizard.typesetter.container.image.tag} | |
| 335 | Wizard.typesetter.themes.version=1.10.2 | |
| 336 | Wizard.typesetter.themes.checksum=d2d3674434d914378af9a845fc363194cb4bc7a983eb3f8b7af38309faae19f6 | |
| 337 | ||
| 338 | Wizard.container.install.command=Installing container using: ''{0}'' | |
| 339 | Wizard.container.install.await=Waiting for installer to finish | |
| 340 | Wizard.container.install.download.started=Download ''{0}'' started | |
| 341 | Wizard.container.install.download.running=Download in progress, please wait | |
| 342 | Wizard.container.process.enter=Running ''{0}'' ''{1}'' | |
| 343 | Wizard.container.process.exit=Process exit code (zero means success): {0} | |
| 344 | Wizard.container.executable.run.scan=''{0}'' is executable: {1} | |
| 345 | Wizard.container.executable.run.error=Cannot run container | |
| 346 | Wizard.container.executable.which=Cannot find container using search command | |
| 347 | Wizard.container.executable.path=Cannot find container using PATH variable | |
| 348 | Wizard.container.executable.registry=Cannot find container using registry | |
| 349 | ||
| 350 | # STEP 1: Introduction panel (all) | |
| 351 | Wizard.typesetter.all.1.install.title=Install typesetting system | |
| 352 | Wizard.typesetter.all.1.install.header=Install typesetting system | |
| 353 | Wizard.typesetter.all.1.install.about.container.link.lbl=${Wizard.typesetter.container.name} | |
| 354 | Wizard.typesetter.all.1.install.about.container.link.url=https://podman.io | |
| 355 | Wizard.typesetter.all.1.install.about.text.1=manages the container for the extensive | |
| 356 | Wizard.typesetter.all.1.install.about.typesetter.link.lbl=${Wizard.typesetter.name} | |
| 357 | Wizard.typesetter.all.1.install.about.typesetter.link.url=https://contextgarden.net | |
| 358 | Wizard.typesetter.all.1.install.about.text.2=\ | |
| 359 | typesetting software, which generates PDF files. This wizard\n\ | |
| 360 | will guide you through the installation process. After each\n\ | |
| 361 | step, you'll be prompted to click a button. Click Next to begin. | |
| 362 | ||
| 363 | # STEP 2: Install container manager (Unix) | |
| 364 | # Append steps to keep numbers stable; sorted programmatically. | |
| 365 | Wizard.typesetter.unix.2.install.container.header=Install ${Wizard.typesetter.container.name} for Linux / macOS / Unix | |
| 366 | # Copy button states | |
| 367 | Wizard.typesetter.unix.2.install.container.copy.began=Copy | |
| 368 | Wizard.typesetter.unix.2.install.container.copy.ended=Copied | |
| 369 | Wizard.typesetter.unix.2.install.container.os=Operating System | |
| 370 | Wizard.typesetter.unix.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}: | |
| 371 | Wizard.typesetter.unix.2.install.container.step.1=\t1. Select this computer's ${Wizard.typesetter.unix.2.install.container.os}. | |
| 372 | Wizard.typesetter.unix.2.install.container.step.2=\t2. Open a new terminal. | |
| 373 | Wizard.typesetter.unix.2.install.container.step.3=\t3. Run the commands provided below in the terminal. | |
| 374 | Wizard.typesetter.unix.2.install.container.step.4=\t4. Click Next to continue. | |
| 375 | Wizard.typesetter.unix.2.install.container.details.prefix=See | |
| 376 | Wizard.typesetter.unix.2.install.container.details.link.lbl=${Wizard.typesetter.container.name}'s instructions | |
| 377 | Wizard.typesetter.unix.2.install.container.details.link.url=https://podman.io/getting-started/installation | |
| 378 | Wizard.typesetter.unix.2.install.container.details.suffix=for more details. | |
| 379 | Wizard.typesetter.unix.2.install.container.command.distros=14 | |
| 380 | Wizard.typesetter.unix.2.install.container.command.os.name.01=Arch Linux & Manjaro Linux | |
| 381 | Wizard.typesetter.unix.2.install.container.command.os.text.01=sudo pacman -S podman | |
| 382 | Wizard.typesetter.unix.2.install.container.command.os.name.02=Alpine Linux | |
| 383 | Wizard.typesetter.unix.2.install.container.command.os.text.02=sudo apk add podman | |
| 384 | Wizard.typesetter.unix.2.install.container.command.os.name.03=CentOS | |
| 385 | Wizard.typesetter.unix.2.install.container.command.os.text.03=sudo yum -y install podman | |
| 386 | Wizard.typesetter.unix.2.install.container.command.os.name.04=Debian | |
| 387 | Wizard.typesetter.unix.2.install.container.command.os.text.04=sudo apt-get -y install podman | |
| 388 | Wizard.typesetter.unix.2.install.container.command.os.name.05=Fedora | |
| 389 | Wizard.typesetter.unix.2.install.container.command.os.text.05=sudo dnf -y install podman | |
| 390 | Wizard.typesetter.unix.2.install.container.command.os.name.06=Gentoo | |
| 391 | Wizard.typesetter.unix.2.install.container.command.os.text.06=sudo emerge app-containers/podman | |
| 392 | Wizard.typesetter.unix.2.install.container.command.os.name.07=OpenEmbedded | |
| 393 | Wizard.typesetter.unix.2.install.container.command.os.text.07=bitbake podman | |
| 394 | Wizard.typesetter.unix.2.install.container.command.os.name.08=openSUSE | |
| 395 | Wizard.typesetter.unix.2.install.container.command.os.text.08=sudo zypper install podman | |
| 396 | Wizard.typesetter.unix.2.install.container.command.os.name.09=RHEL7 | |
| 397 | Wizard.typesetter.unix.2.install.container.command.os.text.09=\ | |
| 398 | sudo subscription-manager repos \ | |
| 399 | --enable=rhel-7-server-extras-rpms\n\ | |
| 400 | sudo yum -y install podman | |
| 401 | Wizard.typesetter.unix.2.install.container.command.os.name.10=RHEL8 | |
| 402 | Wizard.typesetter.unix.2.install.container.command.os.text.10=\ | |
| 403 | sudo yum module enable -y container-tools:rhel8\n\ | |
| 404 | sudo yum module install -y container-tools:rhel8 | |
| 405 | Wizard.typesetter.unix.2.install.container.command.os.name.11=Ubuntu 20.10+ | |
| 406 | Wizard.typesetter.unix.2.install.container.command.os.text.11=\ | |
| 407 | sudo apt-get -y update\n\ | |
| 408 | sudo apt-get -y install podman | |
| 409 | Wizard.typesetter.unix.2.install.container.command.os.name.12=Linuxmint | |
| 410 | Wizard.typesetter.unix.2.install.container.command.os.text.12=${Wizard.typesetter.unix.2.install.container.command.os.text.11} | |
| 411 | Wizard.typesetter.unix.2.install.container.command.os.name.13=Linuxmint LMDE | |
| 412 | Wizard.typesetter.unix.2.install.container.command.os.text.13=${Wizard.typesetter.unix.2.install.container.command.os.text.04} | |
| 413 | Wizard.typesetter.unix.2.install.container.command.os.name.14=macOS | |
| 414 | Wizard.typesetter.unix.2.install.container.command.os.text.14=\ | |
| 415 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \n\ | |
| 416 | brew install podman | |
| 417 | ||
| 418 | # STEP 2 a: Download container manager (Windows) | |
| 419 | Wizard.typesetter.win.2.download.container.header=Download ${Wizard.typesetter.container.name} for Windows | |
| 420 | Wizard.typesetter.win.2.download.container.homepage.link.lbl=${Wizard.typesetter.container.name} | |
| 421 | Wizard.typesetter.win.2.download.container.homepage.link.url=https://podman.io | |
| 422 | Wizard.typesetter.win.2.download.container.download.link.lbl=repository | |
| 423 | Wizard.typesetter.win.2.download.container.download.link.url=https://github.com/containers/podman/releases/download/v${Wizard.typesetter.container.version}/podman-${Wizard.typesetter.container.version}-setup.exe | |
| 424 | Wizard.typesetter.win.2.download.container.paths=Downloading {0} into {1}. | |
| 425 | # suppress inspection "UnusedMessageFormatParameter" | |
| 426 | Wizard.typesetter.win.2.download.container.status.bytes=Downloaded {1} bytes (size unknown). | |
| 427 | Wizard.typesetter.win.2.download.container.status.progress=Downloaded {0} % of {1} bytes. | |
| 428 | Wizard.typesetter.win.2.download.container.status.checksum.ok=File {0} exists. Click Next to continue. | |
| 429 | Wizard.typesetter.win.2.download.container.status.checksum.no=Integrity check failed, {0} may be corrupt. | |
| 430 | Wizard.typesetter.win.2.download.container.status.success=Download successful. Click Next to continue. | |
| 431 | Wizard.typesetter.win.2.download.container.status.failure=Download failed. Check network then click Previous to try again. | |
| 432 | ||
| 433 | # STEP 2 b: Install container manager (Windows) | |
| 434 | Wizard.typesetter.win.2.install.container.header=Install ${Wizard.typesetter.container.name} for Windows | |
| 435 | Wizard.typesetter.win.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}: | |
| 436 | Wizard.typesetter.win.2.install.container.step.1=\t1. Open the task bar. | |
| 437 | Wizard.typesetter.win.2.install.container.step.2=\t2. Click the shield icon to grant permissions. | |
| 438 | Wizard.typesetter.win.2.install.container.step.3=\t3. Click Yes in the User Account Control dialog to install. | |
| 439 | Wizard.typesetter.win.2.install.container.status.running=Installing ... | |
| 440 | Wizard.typesetter.win.2.install.container.status.success=Installation successful.\nClick Next to continue. | |
| 441 | Wizard.typesetter.win.2.install.container.status.failure=Installation failed with exit code {0}. | |
| 442 | Wizard.typesetter.win.2.install.container.status.unknown=Could not determine installer file type: {0} | |
| 443 | ||
| 444 | # STEP 2: Install container manager (Universal, undetected operating system) | |
| 445 | Wizard.typesetter.all.2.install.container.header=Install ${Wizard.typesetter.container.name} | |
| 446 | Wizard.typesetter.all.2.install.container.homepage.lbl=${Wizard.typesetter.container.name} | |
| 447 | Wizard.typesetter.all.2.install.container.homepage.url=https://podman.io | |
| 448 | ||
| 449 | # STEP 3: Initialize container manager (all except Linux) | |
| 450 | Wizard.typesetter.all.3.install.container.header=Initialize ${Wizard.typesetter.container.name} | |
| 451 | Wizard.typesetter.all.3.install.container.correct=${Wizard.typesetter.container.name} initialized.\nClick Next to continue. | |
| 452 | Wizard.typesetter.all.3.install.container.missing=Install ${Wizard.typesetter.container.name} before continuing. | |
| 453 | ||
| 454 | # STEP 4: Install typesetter container image (all) | |
| 455 | Wizard.typesetter.all.4.download.image.header=Download ${Wizard.typesetter.name} image | |
| 456 | Wizard.typesetter.all.4.download.image.correct=Download successful.\nClick Next to continue. | |
| 457 | Wizard.typesetter.all.4.download.image.missing=Install ${Wizard.typesetter.container.name} before continuing. | |
| 458 | ||
| 459 | # STEP 5: Download typesetter themes (all) | |
| 460 | Wizard.typesetter.all.5.download.themes.header=Download ${Wizard.typesetter.name} themes | |
| 461 | Wizard.typesetter.all.5.download.themes.download.link.lbl=repository | |
| 462 | Wizard.typesetter.all.5.download.themes.download.link.url=https://gitlab.com/DaveJarvis/keenwrite-themes/-/releases/${Wizard.typesetter.themes.version}/downloads/theme-pack.zip | |
| 463 | Wizard.typesetter.all.5.download.themes.paths=Downloading {0} into {1}. | |
| 464 | Wizard.typesetter.all.5.download.themes.status.bytes=Downloaded {0} bytes (size unknown). | |
| 465 | Wizard.typesetter.all.5.download.themes.status.progress=Downloaded {0} % of {1} bytes. | |
| 466 | Wizard.typesetter.all.5.download.themes.status.checksum.ok=File {0} exists. Click Finish to continue. | |
| 467 | Wizard.typesetter.all.5.download.themes.status.checksum.no=Integrity check failed, {0} may be corrupt. | |
| 468 | Wizard.typesetter.all.5.download.themes.status.success=Download successful. Click Finish to continue. | |
| 469 | Wizard.typesetter.all.5.download.themes.status.failure=Download failed. Check network then click Previous to try again. | |
| 470 | ||
| 471 | # ######################################################################## | |
| 472 | # Open URL dialog | |
| 473 | # ######################################################################## | |
| 474 | ||
| 475 | Dialog.open_url.title=Open URL | |
| 476 | Dialog.open_url.label.url=URL\: | |
| 477 | Dialog.open_url.prompt.url=https://example.com/filename.md | |
| 478 | ||
| 479 | # ######################################################################## | |
| 480 | # Insert image dialog | |
| 481 | # ######################################################################## | |
| 482 | ||
| 483 | Dialog.image.title=Insert image | |
| 484 | Dialog.image.label.url=File or URL\: | |
| 485 | Dialog.image.label.text=Alternate text\: | |
| 486 | Dialog.image.label.title=Title\: | |
| 487 | Dialog.image.prompt.url=Image resource | |
| 488 | Dialog.image.prompt.text=Image description | |
| 489 | Dialog.image.prompt.title=Image tooltip | |
| 490 | ||
| 491 | # ######################################################################## | |
| 492 | # Insert hyperlink dialog | |
| 493 | # ######################################################################## | |
| 494 | ||
| 495 | Dialog.link.title=Insert hyperlink | |
| 496 | Dialog.link.label.text=Text\: | |
| 497 | Dialog.link.label.url=URL\: | |
| 498 | Dialog.link.label.title=Title\: | |
| 499 | Dialog.link.prompt.text=Hyperlink text | |
| 500 | Dialog.link.prompt.url=https://example.com/index.html | |
| 501 | Dialog.link.prompt.title=Hyperlink tooltip | |
| 502 | ||
| 503 | # ######################################################################## | |
| 504 | # Typesetting settings dialog | |
| 505 | # ######################################################################## | |
| 506 | ||
| 507 | Dialog.typesetting.settings.title=Typesetting export settings | |
| 508 | Dialog.typesetting.settings.header.single=Export current document | |
| 509 | Dialog.typesetting.settings.theme=Theme | |
| 510 | Dialog.typesetting.settings.themes.missing=Install themes into {0}. | |
| 511 | ||
| 512 | Dialog.typesetting.settings.header.multiple=Export multiple documents | |
| 513 | Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-) | |
| 514 | ||
| 515 | # ######################################################################## | |
| 516 | # About dialog | |
| 517 | # ######################################################################## | |
| 518 | ||
| 519 | Dialog.about.title=About {0} | |
| 520 | Dialog.about.header={0} | |
| 521 | Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | |
| 522 | ||
| 523 | # ######################################################################## | |
| 524 | # Application Actions | |
| 525 | # ######################################################################## | |
| 526 | ||
| 527 | Action.file.new.description=Create a new file | |
| 528 | Action.file.new.accelerator=Shortcut+N | |
| 529 | Action.file.new.icon=FILE_ALT | |
| 530 | Action.file.new.text=_New | |
| 531 | ||
| 532 | Action.file.open.description=Open a new file | |
| 533 | Action.file.open.accelerator=Shortcut+O | |
| 534 | Action.file.open.text=_Open... | |
| 535 | Action.file.open.icon=FOLDER_OPEN_ALT | |
| 536 | ||
| 537 | Action.file.open_url.description=Open a URL | |
| 538 | Action.file.open_url.accelerator=Shortcut+Alt+O | |
| 539 | Action.file.open_url.text=Open _URL... | |
| 540 | Action.file.open_url.icon=FOLDER_OPEN_ALT | |
| 541 | ||
| 542 | Action.file.close.description=Close the current document | |
| 543 | Action.file.close.accelerator=Shortcut+W | |
| 544 | Action.file.close.text=_Close | |
| 545 | ||
| 546 | Action.file.close_all.description=Close all open documents | |
| 547 | Action.file.close_all.accelerator=Shortcut+F4 | |
| 548 | Action.file.close_all.text=Close All | |
| 549 | ||
| 550 | Action.file.save.description=Save the document | |
| 551 | Action.file.save.accelerator=Shortcut+S | |
| 552 | Action.file.save.text=_Save | |
| 553 | Action.file.save.icon=FLOPPY_ALT | |
| 554 | ||
| 555 | Action.file.save_as.description=Rename the current document | |
| 556 | Action.file.save_as.text=Save _As | |
| 557 | ||
| 558 | Action.file.save_all.description=Save all open documents | |
| 559 | Action.file.save_all.accelerator=Shortcut+Shift+S | |
| 560 | Action.file.save_all.text=Save A_ll | |
| 561 | ||
| 562 | Action.file.export.pdf.description=Typeset the document | |
| 563 | Action.file.export.pdf.accelerator=Shortcut+P | |
| 564 | Action.file.export.pdf.text=_PDF | |
| 565 | Action.file.export.pdf.icon=FILE_PDF_ALT | |
| 566 | ||
| 567 | Action.file.export.pdf.dir.description=Typeset files in document directory | |
| 568 | Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P | |
| 569 | Action.file.export.pdf.dir.text=_Joined PDF | |
| 570 | Action.file.export.pdf.dir.icon=FILE_PDF_ALT | |
| 571 | ||
| 572 | Action.file.export.pdf.repeat.description=Repeat previous typesetting command | |
| 573 | Action.file.export.pdf.repeat.accelerator=Shortcut+Shift+E | |
| 574 | Action.file.export.pdf.repeat.text=_Repeat Export | |
| 575 | Action.file.export.pdf.repeat.icon=FILE_PDF_ALT | |
| 576 | ||
| 577 | Action.file.export.html.dir.description=Export files in document directory as HTML | |
| 578 | Action.file.export.html.dir.accelerator=Shortcut+Shift+H | |
| 579 | Action.file.export.html.dir.text=Joined _HTML | |
| 580 | Action.file.export.html.dir.icon=HTML5 | |
| 581 | ||
| 582 | Action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 583 | Action.file.export.text=_Export As | |
| 584 | Action.file.export.html_svg.text=HTML and S_VG | |
| 585 | ||
| 586 | Action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 587 | Action.file.export.html_tex.text=HTML and _TeX | |
| 588 | ||
| 589 | Action.file.export.xhtml_tex.description=Export as XHTML + TeX | |
| 590 | Action.file.export.xhtml_tex.text=_XHTML and TeX | |
| 591 | ||
| 592 | Action.file.export.markdown.description=Export the current document as Markdown | |
| 593 | Action.file.export.markdown.text=Markdown | |
| 594 | ||
| 595 | Action.file.exit.description=Quit the application | |
| 596 | Action.file.exit.text=E_xit | |
| 597 | ||
| 598 | ||
| 599 | Action.edit.undo.description=Undo the previous edit | |
| 600 | Action.edit.undo.accelerator=Shortcut+Z | |
| 601 | Action.edit.undo.text=_Undo | |
| 602 | Action.edit.undo.icon=UNDO | |
| 603 | ||
| 604 | Action.edit.redo.description=Redo the previous edit | |
| 605 | Action.edit.redo.accelerator=Shortcut+Y | |
| 606 | Action.edit.redo.text=_Redo | |
| 607 | Action.edit.redo.icon=REPEAT | |
| 608 | ||
| 609 | Action.edit.cut.description=Delete the selected text or line | |
| 610 | Action.edit.cut.accelerator=Shortcut+X | |
| 611 | Action.edit.cut.text=Cu_t | |
| 612 | Action.edit.cut.icon=CUT | |
| 613 | ||
| 614 | Action.edit.copy.description=Copy the selected text | |
| 615 | Action.edit.copy.accelerator=Shortcut+C | |
| 616 | Action.edit.copy.text=_Copy | |
| 617 | Action.edit.copy.icon=COPY | |
| 618 | ||
| 619 | Action.edit.paste.description=Paste from the clipboard | |
| 620 | Action.edit.paste.accelerator=Shortcut+V | |
| 621 | Action.edit.paste.text=_Paste | |
| 622 | Action.edit.paste.icon=PASTE | |
| 623 | ||
| 624 | Action.edit.select_all.description=Highlight the current document text | |
| 625 | Action.edit.select_all.accelerator=Shortcut+A | |
| 626 | Action.edit.select_all.text=Select _All | |
| 627 | ||
| 628 | Action.edit.find.description=Search for text in the document | |
| 629 | Action.edit.find.accelerator=Shortcut+F | |
| 630 | Action.edit.find.text=_Find | |
| 631 | Action.edit.find.icon=SEARCH | |
| 632 | ||
| 633 | Action.edit.find_next.description=Find next occurrence | |
| 634 | Action.edit.find_next.accelerator=F3 | |
| 635 | Action.edit.find_next.text=Find _Next | |
| 636 | ||
| 637 | Action.edit.find_prev.description=Find previous occurrence | |
| 638 | Action.edit.find_prev.accelerator=Shift+F3 | |
| 639 | Action.edit.find_prev.text=Find _Prev | |
| 640 | ||
| 641 | Action.edit.preferences.description=Edit user preferences | |
| 642 | Action.edit.preferences.accelerator=Shortcut+Alt+S | |
| 643 | Action.edit.preferences.text=_Preferences | |
| 644 | ||
| 645 | ||
| 646 | Action.format.bold.description=Insert strong text | |
| 647 | Action.format.bold.accelerator=Shortcut+B | |
| 648 | Action.format.bold.text=_Bold | |
| 649 | Action.format.bold.icon=BOLD | |
| 650 | ||
| 651 | Action.format.italic.description=Insert text emphasis | |
| 652 | Action.format.italic.accelerator=Shortcut+I | |
| 653 | Action.format.italic.text=_Italic | |
| 654 | Action.format.italic.icon=ITALIC | |
| 655 | ||
| 656 | Action.format.monospace.description=Insert monospace text | |
| 657 | Action.format.monospace.accelerator=Shortcut+` | |
| 658 | Action.format.monospace.text=_Monospace | |
| 659 | ||
| 660 | Action.format.superscript.description=Insert superscript text | |
| 661 | Action.format.superscript.accelerator=Shortcut+[ | |
| 662 | Action.format.superscript.text=Su_perscript | |
| 663 | Action.format.superscript.icon=SUPERSCRIPT | |
| 664 | ||
| 665 | Action.format.subscript.description=Insert subscript text | |
| 666 | Action.format.subscript.accelerator=Shortcut+] | |
| 667 | Action.format.subscript.text=Su_bscript | |
| 668 | Action.format.subscript.icon=SUBSCRIPT | |
| 669 | ||
| 670 | Action.format.strikethrough.description=Insert struck text | |
| 671 | Action.format.strikethrough.accelerator=Shortcut+T | |
| 672 | Action.format.strikethrough.text=Stri_kethrough | |
| 673 | Action.format.strikethrough.icon=STRIKETHROUGH | |
| 674 | ||
| 675 | ||
| 676 | Action.insert.blockquote.description=Insert blockquote | |
| 677 | Action.insert.blockquote.accelerator=Shortcut+Q | |
| 678 | Action.insert.blockquote.text=_Blockquote | |
| 679 | Action.insert.blockquote.icon=QUOTE_LEFT | |
| 680 | ||
| 681 | Action.insert.code.description=Insert inline code | |
| 682 | Action.insert.code.accelerator=Shortcut+K | |
| 683 | Action.insert.code.text=Inline _Code | |
| 684 | Action.insert.code.icon=CODE | |
| 685 | ||
| 686 | Action.insert.fenced_code_block.description=Insert code block | |
| 687 | Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 688 | Action.insert.fenced_code_block.text=_Fenced Code Block | |
| 689 | Action.insert.fenced_code_block.prompt.text=Enter code here | |
| 690 | Action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 691 | ||
| 692 | Action.insert.link.description=Insert hyperlink | |
| 693 | Action.insert.link.accelerator=Shortcut+L | |
| 694 | Action.insert.link.text=_Link... | |
| 695 | Action.insert.link.icon=LINK | |
| 696 | ||
| 697 | Action.insert.image.description=Insert image | |
| 698 | Action.insert.image.accelerator=Shortcut+G | |
| 699 | Action.insert.image.text=_Image... | |
| 700 | Action.insert.image.icon=PICTURE_ALT | |
| 701 | ||
| 702 | Action.insert.heading.description=Insert heading level | |
| 703 | Action.insert.heading.accelerator=Shortcut+ | |
| 704 | Action.insert.heading.icon=HEADER | |
| 705 | ||
| 706 | Action.insert.heading_1.description=${Action.insert.heading.description} 1 | |
| 707 | Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1 | |
| 708 | Action.insert.heading_1.text=Heading _1 | |
| 709 | Action.insert.heading_1.icon=${Action.insert.heading.icon} | |
| 710 | ||
| 711 | Action.insert.heading_2.description=${Action.insert.heading.description} 2 | |
| 712 | Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2 | |
| 713 | Action.insert.heading_2.text=Heading _2 | |
| 714 | Action.insert.heading_2.icon=${Action.insert.heading.icon} | |
| 715 | ||
| 716 | Action.insert.heading_3.description=${Action.insert.heading.description} 3 | |
| 717 | Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3 | |
| 718 | Action.insert.heading_3.text=Heading _3 | |
| 719 | Action.insert.heading_3.icon=${Action.insert.heading.icon} | |
| 720 | ||
| 721 | Action.insert.unordered_list.description=Insert bulleted list | |
| 722 | Action.insert.unordered_list.accelerator=Shortcut+U | |
| 723 | Action.insert.unordered_list.text=_Unordered List | |
| 724 | Action.insert.unordered_list.icon=LIST_UL | |
| 725 | ||
| 726 | Action.insert.ordered_list.description=Insert enumerated list | |
| 727 | Action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 728 | Action.insert.ordered_list.text=_Ordered List | |
| 729 | Action.insert.ordered_list.icon=LIST_OL | |
| 730 | ||
| 731 | Action.insert.horizontal_rule.description=Insert horizontal rule | |
| 732 | Action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 733 | Action.insert.horizontal_rule.text=_Horizontal Rule | |
| 734 | Action.insert.horizontal_rule.icon=LIST_OL | |
| 735 | ||
| 736 | ||
| 737 | Action.definition.create.description=Create a new variable | |
| 738 | Action.definition.create.text=_Create | |
| 739 | Action.definition.create.icon=TREE | |
| 740 | Action.definition.create.tooltip=Add new item (Insert) | |
| 741 | ||
| 742 | Action.definition.rename.description=Rename the selected variable | |
| 743 | Action.definition.rename.text=_Rename | |
| 744 | Action.definition.rename.icon=EDIT | |
| 745 | Action.definition.rename.tooltip=Rename selected item (F2) | |
| 746 | ||
| 747 | Action.definition.delete.description=Delete the selected variables | |
| 748 | Action.definition.delete.text=De_lete | |
| 749 | Action.definition.delete.icon=TRASH | |
| 750 | Action.definition.delete.tooltip=Delete selected items (Delete) | |
| 751 | ||
| 752 | Action.definition.insert.description=Insert a variable | |
| 753 | Action.definition.insert.accelerator=Shortcut+Space | |
| 754 | Action.definition.insert.text=_Insert | |
| 755 | Action.definition.insert.icon=STAR | |
| 756 | ||
| 757 | ||
| 758 | Action.view.refresh.description=Clear all caches | |
| 759 | Action.view.refresh.accelerator=F5 | |
| 760 | Action.view.refresh.text=Refresh | |
| 761 | ||
| 762 | Action.view.preview.description=Open document preview | |
| 763 | Action.view.preview.accelerator=F6 | |
| 764 | Action.view.preview.text=Preview | |
| 765 | ||
| 766 | Action.view.outline.description=Open document outline | |
| 767 | Action.view.outline.accelerator=F7 | |
| 768 | Action.view.outline.text=Outline | |
| 769 | ||
| 770 | Action.view.statistics.description=Open document word counts | |
| 771 | Action.view.statistics.accelerator=F8 | |
| 772 | Action.view.statistics.text=Statistics | |
| 773 | ||
| 774 | Action.view.files.description=Open file manager | |
| 775 | Action.view.files.accelerator=Shortcut+F8 | |
| 776 | Action.view.files.text=Files | |
| 777 | ||
| 778 | Action.view.menubar.description=Toggle menu bar | |
| 779 | Action.view.menubar.accelerator=Shortcut+F9 | |
| 780 | Action.view.menubar.text=Menu bar | |
| 781 | ||
| 782 | Action.view.toolbar.description=Toggle toolbar | |
| 783 | Action.view.toolbar.accelerator=Shortcut+Shift+F9 | |
| 784 | Action.view.toolbar.text=Toolbar | |
| 785 | ||
| 786 | Action.view.statusbar.description=Toggle status bar | |
| 787 | Action.view.statusbar.accelerator=Shortcut+Shift+Alt+F9 | |
| 768 | 788 | Action.view.statusbar.text=Status bar |
| 769 | 789 |
| 1 | /* | |
| 2 | * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com> | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 27 | ||
| 28 | 1 | .tool-bar { |
| 29 | 2 | -fx-spacing: 0; |