| 3 | 3 | Import the files in this directory into the application, which include: |
| 4 | 4 | |
| 5 | * bootstrap.R | |
| 5 | 6 | * pluralize.R |
| 6 | 7 | * possessive.R |
| 7 | 8 | * conversion.R |
| 8 | 9 | * csv.R |
| 10 | ||
| 11 | # bootstrap.R | |
| 12 | ||
| 13 | Copy the contents of this file into R script preferences, as shown in the | |
| 14 | following figure, then restart the application for the changes to take | |
| 15 | effect: | |
| 16 | ||
| 17 | #  | |
| 18 | ||
| 19 | Setting the **Working Directory** allows the startup script to load files | |
| 20 | using a relative to said directory. | |
| 9 | 21 | |
| 10 | 22 | # pluralize.R |
| 11 | 23 | |
| 12 | 24 | This file defines a function that implements most of Damian Conway's [An Algorithmic Approach to English Pluralization](http://blob.perl.org/tpc/1998/User_Applications/Algorithmic%20Approach%20Plurals/Algorithmic_Plurals.html). |
| 13 | 25 | |
| 14 | 26 | ## Usage |
| 15 | 27 | |
| 16 | 28 | Example usages of the pluralize function include: |
| 17 | 29 | |
| 18 | `r#pluralize( 'mouse' )` - mice | |
| 19 | `r#pluralize( 'buzz' )` - buzzes | |
| 20 | `r#pluralize( 'bus' )` - busses | |
| 30 | `r#pluralize( "mouse" )` - mice | |
| 31 | `r#pluralize( "buzz" )` - buzzes | |
| 32 | `r#pluralize( "bus" )` - busses | |
| 21 | 33 | |
| 22 | 34 | # possessive.R |
| 23 | 35 | |
| 24 | 36 | This file defines a function that applies possessives to English words. |
| 25 | 37 | |
| 26 | 38 | ## Usage |
| 27 | 39 | |
| 28 | 40 | Example usages of the possessive function include: |
| 29 | 41 | |
| 30 | `r#pos( 'Ross' )` - Ross' | |
| 31 | `r#pos( 'Ruby' )` - Ruby's | |
| 32 | `r#pos( 'Lois' )` - Lois' | |
| 33 | `r#pos( 'my' )` - mine | |
| 34 | `r#pos( 'Your' )` - Yours | |
| 42 | `r#pos( "Ross" )` - Ross' | |
| 43 | `r#pos( "Ruby" )` - Ruby's | |
| 44 | `r#pos( "Lois" )` - Lois' | |
| 45 | `r#pos( "my" )` - mine | |
| 46 | `r#pos( "Your" )` - Yours | |
| 35 | 47 | |
| 36 | 48 |
| 1 | setwd( '{{application.r.working.directory}}' ) | |
| 2 | assign( "anchor", '{{date.anchor}}', envir = .GlobalEnv ) | |
| 1 | setwd( v$application$r$working$directory ) | |
| 3 | 2 | |
| 4 | source( 'pluralize.R' ) | |
| 5 | source( 'possessive.R' ) | |
| 6 | source( 'conversion.R' ) | |
| 7 | source( 'csv.R' ) | |
| 3 | # To reference additional R variables in documents, define them such as: | |
| 4 | # assign( "variable", v$key$name, envir = .GlobalEnv ) | |
| 5 | ||
| 6 | source( "pluralize.R" ) | |
| 7 | source( "possessive.R" ) | |
| 8 | source( "conversion.R" ) | |
| 9 | source( "csv.R" ) | |
| 8 | 10 | |
| 9 | 11 |
| 53 | 53 | df[ (nrow( df ) + 1), number ] <- f.sum( df[, number], na.rm=TRUE ) |
| 54 | 54 | |
| 55 | # pluralise would be heavyweight here. | |
| 55 | # pluralize would be heavyweight here. | |
| 56 | 56 | if( length( number ) > 1 ) { |
| 57 | t <- "**Totals**" | |
| 57 | t <- "Totals" | |
| 58 | 58 | } |
| 59 | 59 | else { |
| 60 | t <- "**Total**" | |
| 60 | t <- "Total" | |
| 61 | 61 | } |
| 62 | 62 | |
| ... | ||
| 70 | 70 | if( align ) { |
| 71 | 71 | is.char <- vapply( df, is.character, logical( 1 ) ) |
| 72 | dashes <- paste( ifelse( is.char, ':---', '---:' ), collapse='|' ) | |
| 72 | dashes <- paste( ifelse( is.char, ':---', '---:' ), collapse = '|' ) | |
| 73 | 73 | } |
| 74 | 74 | else { |
| 75 | 75 | dashes <- paste( rep( '---', length( df ) ), collapse = '|' ) |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | 78 | # Create a Markdown version of the data frame. |
| 79 | 79 | paste( |
| 80 | paste( names( df ), collapse = '|'), '\n', | |
| 81 | dashes, '\n', | |
| 80 | '|', paste( names( df ), collapse = '|'), '|', '\n', | |
| 81 | '|', dashes, '|', '\n', | |
| 82 | 82 | paste( |
| 83 | '|', | |
| 83 | 84 | Reduce( function( x, y ) { |
| 84 | paste( x, format( y, digits = decimals ), sep = '|' ) | |
| 85 | paste( x, format( y, nsmall = decimals ), sep = '|' ) | |
| 85 | 86 | }, df |
| 86 | 87 | ), |
| 87 | 88 | collapse = '|\n', sep='' |
| 88 | ) | |
| 89 | ), | |
| 90 | '|\n', | |
| 91 | sep='' | |
| 89 | 92 | ) |
| 90 | 93 | } |
| 50 | 50 | |
| 51 | 51 | dependencies { |
| 52 | def v_junit = '5.8.1' | |
| 52 | def v_junit = '5.8.2' | |
| 53 | 53 | def v_flexmark = '0.62.2' |
| 54 | def v_jackson = '2.13.0' | |
| 54 | def v_jackson = '2.13.1' | |
| 55 | 55 | def v_batik = '1.14' |
| 56 | 56 | def v_wheatsheaf = '2.0.1' |
| ... | ||
| 119 | 119 | implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3' |
| 120 | 120 | implementation 'javax.validation:validation-api:2.0.1.Final' |
| 121 | implementation 'org.greenrobot:eventbus:3.2.0' | |
| 121 | implementation 'org.greenrobot:eventbus-java:3.3.1' | |
| 122 | 122 | |
| 123 | 123 | implementation 'org.apache.commons:commons-configuration2:2.7' |
| 9 | 9 | * [skins.md](skins.md) -- User interface customization |
| 10 | 10 | * [svg.md](svg.md) -- Resolve issues with some SVG files |
| 11 | * [metadata.md](metadata.md) -- Document metadata | |
| 11 | 12 | * [typesetting.md](typesetting.md) -- Document typesetting |
| 12 | 13 | * [variables.md](variables.md) -- Variable definitions and interpolation |
| 1 | # Document metadata | |
| 2 | ||
| 3 | Document metadata is information about a document. Metadata often includes | |
| 4 | a title, author name, copyright date, and keywords. | |
| 5 | ||
| 6 | # Custom metadata | |
| 7 | ||
| 8 | The following screenshot shows example metadata preferences: | |
| 9 | ||
| 10 |  | |
| 11 | ||
| 12 | The **Key** column lists metadata names and the **Value** column lists | |
| 13 | the metadata content for each corresponding **Key**. The content may | |
| 14 | include references to variable definitions. When the document is typeset, | |
| 15 | the values for the variables will be substituted upon export. | |
| 16 | ||
| 17 | When the document is exported as XHTML, the header will include the | |
| 18 | keys and values conforming to the XHTML specification. For example: | |
| 19 | ||
| 20 | ``` html | |
| 21 | <head> | |
| 22 | <title>Document Title</title> | |
| 23 | <meta content="science, nature" name="keywords"/> | |
| 24 | <meta content="Penn Surnom" name="author"/> | |
| 25 | <meta content="4311" name="count"/> | |
| 26 | </head> | |
| 27 | ``` | |
| 28 | ||
| 29 | # Special metadata | |
| 30 | ||
| 31 | When exporting the document, note the following special metadata: | |
| 32 | ||
| 33 | * **author** -- Included as PDF metadata | |
| 34 | * **byline** -- Replaces author in PDF metadata (e.g., for pen names) | |
| 35 | * **count** -- Total word count in document, automatically included | |
| 36 | * **keywords** -- Included as PDF metadata | |
| 37 | * **title** -- Included as a `<title>` tag, rather than a `<meta>` tag | |
| 38 | ||
| 1 | 39 |
| 90 | 90 | 1. Set the **R Startup Script** contents to: |
| 91 | 91 | ``` r |
| 92 | setwd( '{{application.r.working.directory}}' ); | |
| 93 | source( 'library.R' ); | |
| 92 | setwd( v$application$r$working$directory ); | |
| 93 | source( "library.R" ); | |
| 94 | 94 | ``` |
| 95 | 95 | 1. Change `sum.Rmd` to: |
| ... | ||
| 107 | 107 | ``` |
| 108 | 108 | |
| 109 | Calling `setwd` using `'{{application.r.working.directory}}'` changes the | |
| 109 | Calling `setwd` using `v$application$r$working$directory` changes the | |
| 110 | 110 | working directory where the R engine searches for source files. |
| 111 | 111 | |
| 53 | 53 | final var predicate = createFileTypePredicate( patterns ); |
| 54 | 54 | |
| 55 | if( found = predicate.test( path.toFile() ) ) { | |
| 55 | if( predicate.test( path.toFile() ) ) { | |
| 56 | 56 | // Remove the EXTENSIONS_PREFIX to get the file name extension mapped |
| 57 | 57 | // to a standard name (as defined in the settings.properties file). |
| 58 | 58 | final String suffix = key.replace( prefix + '.', "" ); |
| 59 | 59 | fileType = FileType.from( suffix ); |
| 60 | found = true; | |
| 60 | 61 | } |
| 61 | 62 | } |
| 88 | 88 | * editor's directory; {@code false} means to export only the |
| 89 | 89 | * actively edited file. |
| 90 | * | |
| 90 | 91 | private void file_export_pdf( final Path theme, final boolean concat ) { |
| 91 | 92 | if( Typesetter.canRun() ) { |
| ... | ||
| 124 | 125 | file_export( MARKDOWN_PLAIN ); |
| 125 | 126 | } |
| 126 | */ | |
| 127 | ||
| 127 | */ | |
| 128 | 128 | /** |
| 129 | 129 | * Concatenates all the files in the same directory as the given file into |
| 24 | 24 | * Order matters, this must be populated before deriving the app title. |
| 25 | 25 | */ |
| 26 | private static final Properties P = new Properties(); | |
| 26 | private static final Properties sP = new Properties(); | |
| 27 | 27 | |
| 28 | 28 | static { |
| 29 | 29 | try( final var in = openResource( "/bootstrap.properties" ) ) { |
| 30 | P.load( in ); | |
| 30 | sP.load( in ); | |
| 31 | 31 | } catch( final Exception ignored ) { |
| 32 | 32 | // Bootstrap properties cannot be found, throw in the towel. |
| 33 | 33 | } |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | public static final String APP_TITLE = P.getProperty( "application.title" ); | |
| 36 | public static final String APP_TITLE = sP.getProperty( "application.title" ); | |
| 37 | 37 | public static final String APP_TITLE_LOWERCASE = APP_TITLE.toLowerCase(); |
| 38 | 38 | public static final String APP_VERSION = Launcher.getVersion(); |
| 19 | 19 | public class Caret { |
| 20 | 20 | |
| 21 | private final Mutator mMutator; | |
| 22 | ||
| 21 | 23 | public static GenericBuilder<Caret.Mutator, Caret> builder() { |
| 22 | 24 | return GenericBuilder.of( Caret.Mutator::new, Caret::new ); |
| ... | ||
| 66 | 68 | } |
| 67 | 69 | } |
| 68 | ||
| 69 | private final Mutator mMutator; | |
| 70 | 70 | |
| 71 | 71 | /** |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.editors.TextDefinition; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.editors.definition.DefinitionTreeItem; | |
| 7 | import com.keenwrite.sigils.SigilOperator; | |
| 8 | ||
| 9 | import static com.keenwrite.constants.Constants.*; | |
| 10 | import static com.keenwrite.events.StatusEvent.clue; | |
| 11 | ||
| 12 | /** | |
| 13 | * Provides the logic for injecting variable names within the editor. | |
| 14 | */ | |
| 15 | public final class DefinitionNameInjector { | |
| 16 | ||
| 17 | /** | |
| 18 | * Prevent instantiation. | |
| 19 | */ | |
| 20 | private DefinitionNameInjector() { | |
| 21 | } | |
| 22 | ||
| 23 | /** | |
| 24 | * Find a node that matches the current word and substitute the definition | |
| 25 | * reference. | |
| 26 | */ | |
| 27 | public static void autoinsert( | |
| 28 | final TextEditor editor, | |
| 29 | final TextDefinition definitions, | |
| 30 | final SigilOperator operator ) { | |
| 31 | try { | |
| 32 | if( definitions.isEmpty() ) { | |
| 33 | clue( STATUS_DEFINITION_EMPTY ); | |
| 34 | } | |
| 35 | else { | |
| 36 | final var indexes = editor.getCaretWord(); | |
| 37 | final var word = editor.getText( indexes ); | |
| 38 | ||
| 39 | if( word.isBlank() ) { | |
| 40 | clue( STATUS_DEFINITION_BLANK ); | |
| 41 | } | |
| 42 | else { | |
| 43 | final var leaf = findLeaf( definitions, word ); | |
| 44 | ||
| 45 | if( leaf == null ) { | |
| 46 | clue( STATUS_DEFINITION_MISSING, word ); | |
| 47 | } | |
| 48 | else { | |
| 49 | final var entokened = operator.entoken( leaf.toPath() ); | |
| 50 | editor.replaceText( indexes, operator.apply( entokened ) ); | |
| 51 | definitions.expand( leaf ); | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | } catch( final Exception ex ) { | |
| 56 | clue( STATUS_DEFINITION_BLANK, ex ); | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Looks for the given word, matching first by exact, next by a starts-with | |
| 62 | * condition with diacritics replaced, then by containment. | |
| 63 | * | |
| 64 | * @param word Match the word by: exact, beginning, containment, or other. | |
| 65 | */ | |
| 66 | @SuppressWarnings( "ConstantConditions" ) | |
| 67 | private static DefinitionTreeItem<String> findLeaf( | |
| 68 | final TextDefinition definition, final String word ) { | |
| 69 | assert word != null; | |
| 70 | ||
| 71 | DefinitionTreeItem<String> leaf = null; | |
| 72 | ||
| 73 | leaf = leaf == null ? definition.findLeafExact( word ) : leaf; | |
| 74 | leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf; | |
| 75 | leaf = leaf == null ? definition.findLeafContains( word ) : leaf; | |
| 76 | leaf = leaf == null ? definition.findLeafContainsNoCase( word ) : leaf; | |
| 77 | ||
| 78 | return leaf; | |
| 79 | } | |
| 80 | } | |
| 81 | 1 |
| 52 | 52 | private final String mExtension; |
| 53 | 53 | |
| 54 | ExportFormat( final String extension ) { | |
| 55 | mExtension = extension; | |
| 56 | } | |
| 57 | ||
| 58 | 54 | /** |
| 59 | 55 | * Looks up the {@link ExportFormat} based on the given format type and |
| ... | ||
| 83 | 79 | ) ); |
| 84 | 80 | }; |
| 81 | } | |
| 82 | ||
| 83 | ExportFormat( final String extension ) { | |
| 84 | mExtension = extension; | |
| 85 | 85 | } |
| 86 | 86 | |
| 32 | 32 | private final String[] mArgs; |
| 33 | 33 | |
| 34 | /** | |
| 35 | * Delegates running the application via the command-line argument parser. | |
| 36 | * This is the main entry point for the application, regardless of whether | |
| 37 | * run from the command-line or as a GUI. | |
| 38 | * | |
| 39 | * @param args Command-line arguments. | |
| 40 | */ | |
| 41 | public static void main( final String[] args ) { | |
| 42 | installTrustManager(); | |
| 43 | parse( args ); | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * @param args Command-line arguments (passed into the GUI). | |
| 48 | */ | |
| 49 | public Launcher( final String[] args ) { | |
| 50 | mArgs = args; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Called after the arguments have been parsed. | |
| 55 | * | |
| 56 | * @param args The parsed command-line arguments. | |
| 57 | */ | |
| 58 | @Override | |
| 59 | public void accept( final Arguments args ) { | |
| 60 | assert args != null; | |
| 61 | ||
| 62 | try { | |
| 63 | int argCount = mArgs.length; | |
| 64 | ||
| 65 | if( args.quiet() ) { | |
| 66 | argCount--; | |
| 67 | } | |
| 68 | else { | |
| 69 | showAppInfo(); | |
| 70 | } | |
| 71 | ||
| 72 | if( args.debug() ) { | |
| 73 | argCount--; | |
| 74 | } | |
| 75 | else { | |
| 76 | disableLogging(); | |
| 77 | } | |
| 78 | ||
| 79 | if( argCount <= 0 ) { | |
| 80 | // When no command-line arguments are provided, launch the GUI. | |
| 81 | MainApp.main( mArgs ); | |
| 82 | } | |
| 83 | else { | |
| 84 | // When command-line arguments are supplied, run in headless mode. | |
| 85 | HeadlessApp.main( args ); | |
| 86 | } | |
| 87 | } catch( final Throwable t ) { | |
| 88 | log( t ); | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | 34 | private static void parse( final String[] args ) { |
| 93 | 35 | assert args != null; |
| ... | ||
| 183 | 125 | private static void out( final String message, final Object... args ) { |
| 184 | 126 | System.out.printf( format( "%s%n", message ), args ); |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Delegates running the application via the command-line argument parser. | |
| 131 | * This is the main entry point for the application, regardless of whether | |
| 132 | * run from the command-line or as a GUI. | |
| 133 | * | |
| 134 | * @param args Command-line arguments. | |
| 135 | */ | |
| 136 | public static void main( final String[] args ) { | |
| 137 | installTrustManager(); | |
| 138 | parse( args ); | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * @param args Command-line arguments (passed into the GUI). | |
| 143 | */ | |
| 144 | public Launcher( final String[] args ) { | |
| 145 | mArgs = args; | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Called after the arguments have been parsed. | |
| 150 | * | |
| 151 | * @param args The parsed command-line arguments. | |
| 152 | */ | |
| 153 | @Override | |
| 154 | public void accept( final Arguments args ) { | |
| 155 | assert args != null; | |
| 156 | ||
| 157 | try { | |
| 158 | int argCount = mArgs.length; | |
| 159 | ||
| 160 | if( args.quiet() ) { | |
| 161 | argCount--; | |
| 162 | } | |
| 163 | else { | |
| 164 | showAppInfo(); | |
| 165 | } | |
| 166 | ||
| 167 | if( args.debug() ) { | |
| 168 | argCount--; | |
| 169 | } | |
| 170 | else { | |
| 171 | disableLogging(); | |
| 172 | } | |
| 173 | ||
| 174 | if( argCount <= 0 ) { | |
| 175 | // When no command-line arguments are provided, launch the GUI. | |
| 176 | MainApp.main( mArgs ); | |
| 177 | } | |
| 178 | else { | |
| 179 | // When command-line arguments are supplied, run in headless mode. | |
| 180 | HeadlessApp.main( args ); | |
| 181 | } | |
| 182 | } catch( final Throwable t ) { | |
| 183 | log( t ); | |
| 184 | } | |
| 185 | 185 | } |
| 186 | ||
| 186 | 187 | } |
| 187 | 188 | |
| 2 | 2 | package com.keenwrite; |
| 3 | 3 | |
| 4 | import com.keenwrite.cmdline.HeadlessApp; | |
| 4 | 5 | import com.keenwrite.events.HyperlinkOpenEvent; |
| 5 | 6 | import com.keenwrite.preferences.Workspace; |
| ... | ||
| 17 | 18 | import static com.keenwrite.constants.GraphicsConstants.LOGOS; |
| 18 | 19 | import static com.keenwrite.events.Bus.register; |
| 19 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 20 | import static com.keenwrite.preferences.AppKeys.*; | |
| 20 | 21 | import static com.keenwrite.util.FontLoader.initFonts; |
| 21 | 22 | import static javafx.scene.input.KeyCode.ALT; |
| ... | ||
| 35 | 36 | |
| 36 | 37 | /** |
| 37 | * Application entry point. | |
| 38 | * GUI application entry point. See {@link HeadlessApp} for the entry | |
| 39 | * point to the command-line application. | |
| 38 | 40 | * |
| 39 | 41 | * @param args Command-line arguments. |
| 40 | 42 | */ |
| 41 | 43 | public static void main( final String[] args ) { |
| 42 | 44 | launch( args ); |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Creates an instance of {@link KeyEvent} that represents pressing a key. | |
| 49 | * | |
| 50 | * @param code The key to simulate being pressed down. | |
| 51 | * @param shift Whether shift key modifier shall modify the key code. | |
| 52 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 53 | * a key being pressed. | |
| 54 | */ | |
| 55 | public static Event keyDown( final KeyCode code, final boolean shift ) { | |
| 56 | return keyEvent( KEY_PRESSED, code, shift ); | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * Creates an instance of {@link KeyEvent} that represents releasing a key. | |
| 61 | * | |
| 62 | * @param code The key to simulate being released up. | |
| 63 | * @param shift Whether shift key modifier shall modify the key code. | |
| 64 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 65 | * a key being released. | |
| 66 | */ | |
| 67 | public static Event keyUp( final KeyCode code, final boolean shift ) { | |
| 68 | return keyEvent( KEY_RELEASED, code, shift ); | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Creates an instance of {@link KeyEvent} that represents a key released | |
| 73 | * event without any modifier keys held. | |
| 74 | * | |
| 75 | * @param code The key code representing a key to simulate releasing. | |
| 76 | * @return An instance of {@link KeyEvent}. | |
| 77 | */ | |
| 78 | public static Event keyUp( final KeyCode code ) { | |
| 79 | return keyUp( code, false ); | |
| 80 | } | |
| 81 | ||
| 82 | private static Event keyEvent( | |
| 83 | final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) { | |
| 84 | return new KeyEvent( | |
| 85 | type, "", "", code, shift, false, false, false | |
| 86 | ); | |
| 43 | 87 | } |
| 44 | 88 | |
| ... | ||
| 67 | 111 | final var enable = createBoundsEnabledSupplier( stage ); |
| 68 | 112 | |
| 69 | stage.setX( mWorkspace.toDouble( KEY_UI_WINDOW_X ) ); | |
| 70 | stage.setY( mWorkspace.toDouble( KEY_UI_WINDOW_Y ) ); | |
| 71 | stage.setWidth( mWorkspace.toDouble( KEY_UI_WINDOW_W ) ); | |
| 72 | stage.setHeight( mWorkspace.toDouble( KEY_UI_WINDOW_H ) ); | |
| 73 | stage.setMaximized( mWorkspace.toBoolean( KEY_UI_WINDOW_MAX ) ); | |
| 74 | stage.setFullScreen( mWorkspace.toBoolean( KEY_UI_WINDOW_FULL ) ); | |
| 113 | stage.setX( mWorkspace.getDouble( KEY_UI_WINDOW_X ) ); | |
| 114 | stage.setY( mWorkspace.getDouble( KEY_UI_WINDOW_Y ) ); | |
| 115 | stage.setWidth( mWorkspace.getDouble( KEY_UI_WINDOW_W ) ); | |
| 116 | stage.setHeight( mWorkspace.getDouble( KEY_UI_WINDOW_H ) ); | |
| 117 | stage.setMaximized( mWorkspace.getBoolean( KEY_UI_WINDOW_MAX ) ); | |
| 118 | stage.setFullScreen( mWorkspace.getBoolean( KEY_UI_WINDOW_FULL ) ); | |
| 75 | 119 | |
| 76 | 120 | mWorkspace.listen( KEY_UI_WINDOW_X, stage.xProperty(), enable ); |
| ... | ||
| 140 | 184 | return () -> |
| 141 | 185 | !(stage.isMaximized() || stage.isFullScreen() || stage.isIconified()); |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * Creates an instance of {@link KeyEvent} that represents pressing a key. | |
| 146 | * | |
| 147 | * @param code The key to simulate being pressed down. | |
| 148 | * @param shift Whether shift key modifier shall modify the key code. | |
| 149 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 150 | * a key being pressed. | |
| 151 | */ | |
| 152 | public static Event keyDown( final KeyCode code, final boolean shift ) { | |
| 153 | return keyEvent( KEY_PRESSED, code, shift ); | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Creates an instance of {@link KeyEvent} that represents releasing a key. | |
| 158 | * | |
| 159 | * @param code The key to simulate being released up. | |
| 160 | * @param shift Whether shift key modifier shall modify the key code. | |
| 161 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 162 | * a key being released. | |
| 163 | */ | |
| 164 | public static Event keyUp( final KeyCode code, final boolean shift ) { | |
| 165 | return keyEvent( KEY_RELEASED, code, shift ); | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * Creates an instance of {@link KeyEvent} that represents a key released | |
| 170 | * event without any modifier keys held. | |
| 171 | * | |
| 172 | * @param code The key code representing a key to simulate releasing. | |
| 173 | * @return An instance of {@link KeyEvent}. | |
| 174 | */ | |
| 175 | public static Event keyUp( final KeyCode code ) { | |
| 176 | return keyUp( code, false ); | |
| 177 | } | |
| 178 | ||
| 179 | private static Event keyEvent( | |
| 180 | final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) { | |
| 181 | return new KeyEvent( | |
| 182 | type, "", "", code, shift, false, false, false | |
| 183 | ); | |
| 184 | 186 | } |
| 185 | 187 | } |
| 14 | 14 | import com.keenwrite.preferences.Workspace; |
| 15 | 15 | import com.keenwrite.preview.HtmlPreview; |
| 16 | import com.keenwrite.processors.Processor; | |
| 17 | import com.keenwrite.processors.ProcessorContext; | |
| 18 | import com.keenwrite.processors.ProcessorFactory; | |
| 19 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 20 | import com.keenwrite.service.events.Notifier; | |
| 21 | import com.keenwrite.sigils.RSigilOperator; | |
| 22 | import com.keenwrite.sigils.SigilOperator; | |
| 23 | import com.keenwrite.sigils.Sigils; | |
| 24 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 25 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 26 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 27 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 28 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 29 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 30 | import javafx.application.Platform; | |
| 31 | import javafx.beans.property.*; | |
| 32 | import javafx.collections.ListChangeListener; | |
| 33 | import javafx.concurrent.Task; | |
| 34 | import javafx.event.ActionEvent; | |
| 35 | import javafx.event.Event; | |
| 36 | import javafx.event.EventHandler; | |
| 37 | import javafx.scene.Node; | |
| 38 | import javafx.scene.Scene; | |
| 39 | import javafx.scene.control.*; | |
| 40 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 41 | import javafx.scene.input.KeyEvent; | |
| 42 | import javafx.scene.layout.FlowPane; | |
| 43 | import javafx.stage.Stage; | |
| 44 | import javafx.stage.Window; | |
| 45 | import org.greenrobot.eventbus.Subscribe; | |
| 46 | ||
| 47 | import java.io.File; | |
| 48 | import java.io.FileNotFoundException; | |
| 49 | import java.nio.file.Path; | |
| 50 | import java.util.*; | |
| 51 | import java.util.concurrent.ExecutorService; | |
| 52 | import java.util.concurrent.ScheduledExecutorService; | |
| 53 | import java.util.concurrent.ScheduledFuture; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.concurrent.atomic.AtomicReference; | |
| 56 | import java.util.function.Function; | |
| 57 | import java.util.stream.Collectors; | |
| 58 | ||
| 59 | import static com.keenwrite.ExportFormat.NONE; | |
| 60 | import static com.keenwrite.Messages.get; | |
| 61 | import static com.keenwrite.constants.Constants.*; | |
| 62 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 63 | import static com.keenwrite.events.Bus.register; | |
| 64 | import static com.keenwrite.events.StatusEvent.clue; | |
| 65 | import static com.keenwrite.io.MediaType.*; | |
| 66 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 67 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 68 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 69 | import static java.lang.String.format; | |
| 70 | import static java.lang.System.getProperty; | |
| 71 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 72 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 73 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 74 | import static java.util.stream.Collectors.groupingBy; | |
| 75 | import static javafx.application.Platform.runLater; | |
| 76 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 77 | import static javafx.scene.control.ButtonType.*; | |
| 78 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 79 | import static javafx.scene.input.KeyCode.SPACE; | |
| 80 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 81 | import static javafx.util.Duration.millis; | |
| 82 | import static javax.swing.SwingUtilities.invokeLater; | |
| 83 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 84 | ||
| 85 | /** | |
| 86 | * Responsible for wiring together the main application components for a | |
| 87 | * particular {@link Workspace} (project). These include the definition views, | |
| 88 | * text editors, and preview pane along with any corresponding controllers. | |
| 89 | */ | |
| 90 | public final class MainPane extends SplitPane { | |
| 91 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 92 | ||
| 93 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 94 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 95 | new AtomicReference<>(); | |
| 96 | ||
| 97 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 98 | ||
| 99 | /** | |
| 100 | * Used when opening files to determine how each file should be binned and | |
| 101 | * therefore what tab pane to be opened within. | |
| 102 | */ | |
| 103 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 104 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 105 | ); | |
| 106 | ||
| 107 | /** | |
| 108 | * Prevents re-instantiation of processing classes. | |
| 109 | */ | |
| 110 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 111 | new HashMap<>(); | |
| 112 | ||
| 113 | private final Workspace mWorkspace; | |
| 114 | ||
| 115 | /** | |
| 116 | * Groups similar file type tabs together. | |
| 117 | */ | |
| 118 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 119 | ||
| 120 | /** | |
| 121 | * Renders the actively selected plain text editor tab. | |
| 122 | */ | |
| 123 | private final HtmlPreview mPreview; | |
| 124 | ||
| 125 | /** | |
| 126 | * Provides an interactive document outline. | |
| 127 | */ | |
| 128 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 129 | ||
| 130 | /** | |
| 131 | * Changing the active editor fires the value changed event. This allows | |
| 132 | * refreshes to happen when external definitions are modified and need to | |
| 133 | * trigger the processing chain. | |
| 134 | */ | |
| 135 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 136 | createActiveTextEditor(); | |
| 137 | ||
| 138 | /** | |
| 139 | * Changing the active definition editor fires the value changed event. This | |
| 140 | * allows refreshes to happen when external definitions are modified and need | |
| 141 | * to trigger the processing chain. | |
| 142 | */ | |
| 143 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor; | |
| 144 | ||
| 145 | /** | |
| 146 | * Called when the definition data is changed. | |
| 147 | */ | |
| 148 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 149 | event -> { | |
| 150 | process( getActiveTextEditor() ); | |
| 151 | save( getActiveTextDefinition() ); | |
| 152 | }; | |
| 153 | ||
| 154 | /** | |
| 155 | * Tracks the number of detached tab panels opened into their own windows, | |
| 156 | * which allows unique identification of subordinate windows by their title. | |
| 157 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 158 | */ | |
| 159 | private byte mWindowCount; | |
| 160 | ||
| 161 | private final DocumentStatistics mStatistics; | |
| 162 | ||
| 163 | /** | |
| 164 | * Adds all content panels to the main user interface. This will load the | |
| 165 | * configuration settings from the workspace to reproduce the settings from | |
| 166 | * a previous session. | |
| 167 | */ | |
| 168 | public MainPane( final Workspace workspace ) { | |
| 169 | mWorkspace = workspace; | |
| 170 | mPreview = new HtmlPreview( workspace ); | |
| 171 | mStatistics = new DocumentStatistics( workspace ); | |
| 172 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 173 | mActiveDefinitionEditor = createActiveDefinitionEditor( mActiveTextEditor ); | |
| 174 | ||
| 175 | open( collect( getRecentFiles() ) ); | |
| 176 | viewPreview(); | |
| 177 | setDividerPositions( calculateDividerPositions() ); | |
| 178 | ||
| 179 | // Once the main scene's window regains focus, update the active definition | |
| 180 | // editor to the currently selected tab. | |
| 181 | runLater( () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 182 | // Order matters here. We want to close all the tabs to ensure each | |
| 183 | // is saved, but after they are closed, the workspace should still | |
| 184 | // retain the list of files that were open. If this line came after | |
| 185 | // closing, then restarting the application would list no files. | |
| 186 | mWorkspace.save(); | |
| 187 | ||
| 188 | if( closeAll() ) { | |
| 189 | Platform.exit(); | |
| 190 | System.exit( 0 ); | |
| 191 | } | |
| 192 | else { | |
| 193 | event.consume(); | |
| 194 | } | |
| 195 | } ) ); | |
| 196 | ||
| 197 | register( this ); | |
| 198 | initAutosave( workspace ); | |
| 199 | } | |
| 200 | ||
| 201 | @Subscribe | |
| 202 | public void handle( final TextEditorFocusEvent event ) { | |
| 203 | mActiveTextEditor.set( event.get() ); | |
| 204 | } | |
| 205 | ||
| 206 | @Subscribe | |
| 207 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 208 | mActiveDefinitionEditor.set( event.get() ); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Typically called when a file name is clicked in the preview panel. | |
| 213 | * | |
| 214 | * @param event The event to process, must contain a valid file reference. | |
| 215 | */ | |
| 216 | @Subscribe | |
| 217 | public void handle( final FileOpenEvent event ) { | |
| 218 | final File eventFile; | |
| 219 | final var eventUri = event.getUri(); | |
| 220 | ||
| 221 | if( eventUri.isAbsolute() ) { | |
| 222 | eventFile = new File( eventUri.getPath() ); | |
| 223 | } | |
| 224 | else { | |
| 225 | final var activeFile = getActiveTextEditor().getFile(); | |
| 226 | final var parent = activeFile.getParentFile(); | |
| 227 | ||
| 228 | if( parent == null ) { | |
| 229 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 230 | return; | |
| 231 | } | |
| 232 | else { | |
| 233 | final var parentPath = parent.getAbsolutePath(); | |
| 234 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | runLater( () -> open( eventFile ) ); | |
| 239 | } | |
| 240 | ||
| 241 | @Subscribe | |
| 242 | public void handle( final CaretNavigationEvent event ) { | |
| 243 | runLater( () -> { | |
| 244 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 245 | textArea.moveTo( event.getOffset() ); | |
| 246 | textArea.requestFollowCaret(); | |
| 247 | textArea.requestFocus(); | |
| 248 | } ); | |
| 249 | } | |
| 250 | ||
| 251 | @Subscribe | |
| 252 | @SuppressWarnings( "unused" ) | |
| 253 | public void handle( final ExportFailedEvent event ) { | |
| 254 | final var os = getProperty( "os.name" ); | |
| 255 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 256 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 257 | ||
| 258 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 259 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 260 | final var version = Messages.get( | |
| 261 | "Alert.typesetter.missing.version", | |
| 262 | os, | |
| 263 | arch | |
| 264 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 265 | .replaceAll( "mips.*", "MIPS" ) | |
| 266 | .replaceAll( "armv.*", "ARM" ), | |
| 267 | bits ); | |
| 268 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 269 | ||
| 270 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 271 | final var content = format( "%s %s", text, version ); | |
| 272 | final var flowPane = new FlowPane(); | |
| 273 | final var link = new Hyperlink( text ); | |
| 274 | final var label = new Label( version ); | |
| 275 | flowPane.getChildren().addAll( link, label ); | |
| 276 | ||
| 277 | final var alert = new Alert( ERROR, content, OK ); | |
| 278 | alert.setTitle( title ); | |
| 279 | alert.setHeaderText( header ); | |
| 280 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 281 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 282 | ||
| 283 | link.setOnAction( ( e ) -> { | |
| 284 | alert.close(); | |
| 285 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 286 | runLater( () -> HyperlinkOpenEvent.fire( url ) ); | |
| 287 | } ); | |
| 288 | ||
| 289 | alert.showAndWait(); | |
| 290 | } | |
| 291 | ||
| 292 | private void initAutosave( final Workspace workspace ) { | |
| 293 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 294 | ||
| 295 | rate.addListener( | |
| 296 | ( c, o, n ) -> { | |
| 297 | final var taskRef = mSaveTask.get(); | |
| 298 | ||
| 299 | // Prevent multiple autosaves from running. | |
| 300 | if( taskRef != null ) { | |
| 301 | taskRef.cancel( false ); | |
| 302 | } | |
| 303 | ||
| 304 | initAutosave( rate ); | |
| 305 | } | |
| 306 | ); | |
| 307 | ||
| 308 | // Start the save listener (avoids duplicating some code). | |
| 309 | initAutosave( rate ); | |
| 310 | } | |
| 311 | ||
| 312 | private void initAutosave( final IntegerProperty rate ) { | |
| 313 | mSaveTask.set( | |
| 314 | mSaver.scheduleAtFixedRate( | |
| 315 | () -> { | |
| 316 | if( getActiveTextEditor().isModified() ) { | |
| 317 | // Ensure the modified indicator is cleared by running on EDT. | |
| 318 | runLater( this::save ); | |
| 319 | } | |
| 320 | }, 0, rate.intValue(), SECONDS | |
| 321 | ) | |
| 322 | ); | |
| 323 | } | |
| 324 | ||
| 325 | /** | |
| 326 | * TODO: Load divider positions from exported settings, see | |
| 327 | * {@link #collect(SetProperty)} comment. | |
| 328 | */ | |
| 329 | private double[] calculateDividerPositions() { | |
| 330 | final var ratio = 100f / getItems().size() / 100; | |
| 331 | final var positions = getDividerPositions(); | |
| 332 | ||
| 333 | for( int i = 0; i < positions.length; i++ ) { | |
| 334 | positions[ i ] = ratio * i; | |
| 335 | } | |
| 336 | ||
| 337 | return positions; | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Opens all the files into the application, provided the paths are unique. | |
| 342 | * This may only be called for any type of files that a user can edit | |
| 343 | * (i.e., update and persist), such as definitions and text files. | |
| 344 | * | |
| 345 | * @param files The list of files to open. | |
| 346 | */ | |
| 347 | public void open( final List<File> files ) { | |
| 348 | files.forEach( this::open ); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * This opens the given file. Since the preview pane is not a file that | |
| 353 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 354 | * | |
| 355 | * @param inputFile The file to open. | |
| 356 | */ | |
| 357 | private void open( final File inputFile ) { | |
| 358 | final var tab = createTab( inputFile ); | |
| 359 | final var node = tab.getContent(); | |
| 360 | final var mediaType = MediaType.valueFrom( inputFile ); | |
| 361 | final var tabPane = obtainTabPane( mediaType ); | |
| 362 | ||
| 363 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 364 | tabPane.setFocusTraversable( false ); | |
| 365 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 366 | tabPane.getTabs().add( tab ); | |
| 367 | ||
| 368 | // Attach the tab scene factory for new tab panes. | |
| 369 | if( !getItems().contains( tabPane ) ) { | |
| 370 | addTabPane( | |
| 371 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 372 | ); | |
| 373 | } | |
| 374 | ||
| 375 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 376 | } | |
| 377 | ||
| 378 | /** | |
| 379 | * Opens a new text editor document using the default document file name. | |
| 380 | */ | |
| 381 | public void newTextEditor() { | |
| 382 | open( DOCUMENT_DEFAULT ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Opens a new definition editor document using the default definition | |
| 387 | * file name. | |
| 388 | */ | |
| 389 | public void newDefinitionEditor() { | |
| 390 | open( DEFINITION_DEFAULT ); | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 395 | * that they save themselves. | |
| 396 | */ | |
| 397 | public void saveAll() { | |
| 398 | mTabPanes.forEach( | |
| 399 | ( tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 400 | final var node = tab.getContent(); | |
| 401 | if( node instanceof final TextEditor editor ) { | |
| 402 | save( editor ); | |
| 403 | } | |
| 404 | } ) | |
| 405 | ); | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 410 | * checking if modified first because if the user swaps external media from | |
| 411 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 412 | * the user: save always re-saves. Also, it's less code. | |
| 413 | */ | |
| 414 | public void save() { | |
| 415 | save( getActiveTextEditor() ); | |
| 416 | } | |
| 417 | ||
| 418 | /** | |
| 419 | * Saves the active {@link TextEditor} under a new name. | |
| 420 | * | |
| 421 | * @param files The new active editor {@link File} reference, must contain | |
| 422 | * at least one element. | |
| 423 | */ | |
| 424 | public void saveAs( final List<File> files ) { | |
| 425 | assert files != null; | |
| 426 | assert !files.isEmpty(); | |
| 427 | final var editor = getActiveTextEditor(); | |
| 428 | final var tab = getTab( editor ); | |
| 429 | final var file = files.get( 0 ); | |
| 430 | ||
| 431 | editor.rename( file ); | |
| 432 | tab.ifPresent( t -> { | |
| 433 | t.setText( editor.getFilename() ); | |
| 434 | t.setTooltip( createTooltip( file ) ); | |
| 435 | } ); | |
| 436 | ||
| 437 | save(); | |
| 438 | } | |
| 439 | ||
| 440 | /** | |
| 441 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 442 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 443 | * | |
| 444 | * @param resource The resource to export. | |
| 445 | */ | |
| 446 | private void save( final TextResource resource ) { | |
| 447 | try { | |
| 448 | resource.save(); | |
| 449 | } catch( final Exception ex ) { | |
| 450 | clue( ex ); | |
| 451 | sNotifier.alert( | |
| 452 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 453 | ); | |
| 454 | } | |
| 455 | } | |
| 456 | ||
| 457 | /** | |
| 458 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 459 | * | |
| 460 | * @return {@code true} when all editors, modified or otherwise, were | |
| 461 | * permitted to close; {@code false} when one or more editors were modified | |
| 462 | * and the user requested no closing. | |
| 463 | */ | |
| 464 | public boolean closeAll() { | |
| 465 | var closable = true; | |
| 466 | ||
| 467 | for( final var tabPane : mTabPanes ) { | |
| 468 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 469 | ||
| 470 | while( tabIterator.hasNext() ) { | |
| 471 | final var tab = tabIterator.next(); | |
| 472 | final var resource = tab.getContent(); | |
| 473 | ||
| 474 | // The definition panes auto-save, so being specific here prevents | |
| 475 | // closing the definitions in the situation where the user wants to | |
| 476 | // continue editing (i.e., possibly save unsaved work). | |
| 477 | if( !(resource instanceof TextEditor) ) { | |
| 478 | continue; | |
| 479 | } | |
| 480 | ||
| 481 | if( canClose( (TextEditor) resource ) ) { | |
| 482 | tabIterator.remove(); | |
| 483 | close( tab ); | |
| 484 | } | |
| 485 | else { | |
| 486 | closable = false; | |
| 487 | } | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | return closable; | |
| 492 | } | |
| 493 | ||
| 494 | /** | |
| 495 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 496 | * event. | |
| 497 | * | |
| 498 | * @param tab The {@link Tab} that was closed. | |
| 499 | */ | |
| 500 | private void close( final Tab tab ) { | |
| 501 | assert tab != null; | |
| 502 | ||
| 503 | final var handler = tab.getOnClosed(); | |
| 504 | ||
| 505 | if( handler != null ) { | |
| 506 | handler.handle( new ActionEvent() ); | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | /** | |
| 511 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 512 | */ | |
| 513 | public void close() { | |
| 514 | final var editor = getActiveTextEditor(); | |
| 515 | ||
| 516 | if( canClose( editor ) ) { | |
| 517 | close( editor ); | |
| 518 | } | |
| 519 | } | |
| 520 | ||
| 521 | /** | |
| 522 | * Closes the given {@link TextResource}. This must not be called from within | |
| 523 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 524 | * concurrent modification exception be thrown. | |
| 525 | * | |
| 526 | * @param resource The {@link TextResource} to close, without confirming with | |
| 527 | * the user. | |
| 528 | */ | |
| 529 | private void close( final TextResource resource ) { | |
| 530 | getTab( resource ).ifPresent( | |
| 531 | ( tab ) -> { | |
| 532 | close( tab ); | |
| 533 | tab.getTabPane().getTabs().remove( tab ); | |
| 534 | } | |
| 535 | ); | |
| 536 | } | |
| 537 | ||
| 538 | /** | |
| 539 | * Answers whether the given {@link TextResource} may be closed. | |
| 540 | * | |
| 541 | * @param editor The {@link TextResource} to try closing. | |
| 542 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 543 | * the user has requested to keep the editor open. | |
| 544 | */ | |
| 545 | private boolean canClose( final TextResource editor ) { | |
| 546 | final var editorTab = getTab( editor ); | |
| 547 | final var canClose = new AtomicBoolean( true ); | |
| 548 | ||
| 549 | if( editor.isModified() ) { | |
| 550 | final var filename = new StringBuilder(); | |
| 551 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 552 | ||
| 553 | final var message = sNotifier.createNotification( | |
| 554 | Messages.get( "Alert.file.close.title" ), | |
| 555 | Messages.get( "Alert.file.close.text" ), | |
| 556 | filename.toString() | |
| 557 | ); | |
| 558 | ||
| 559 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 560 | ||
| 561 | dialog.showAndWait().ifPresent( | |
| 562 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 563 | ); | |
| 564 | } | |
| 565 | ||
| 566 | return canClose.get(); | |
| 567 | } | |
| 568 | ||
| 569 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 570 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 571 | ||
| 572 | editor.addListener( ( c, o, n ) -> { | |
| 573 | if( n != null ) { | |
| 574 | mPreview.setBaseUri( n.getPath() ); | |
| 575 | process( n ); | |
| 576 | } | |
| 577 | } ); | |
| 578 | ||
| 579 | return editor; | |
| 580 | } | |
| 581 | ||
| 582 | /** | |
| 583 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 584 | */ | |
| 585 | public void viewPreview() { | |
| 586 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 587 | } | |
| 588 | ||
| 589 | /** | |
| 590 | * Adds the document outline tab to its own, singular tab pane. | |
| 591 | */ | |
| 592 | public void viewOutline() { | |
| 593 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 594 | } | |
| 595 | ||
| 596 | public void viewStatistics() { | |
| 597 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 598 | } | |
| 599 | ||
| 600 | public void viewFiles() { | |
| 601 | try { | |
| 602 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 603 | final var fileManager = factory.createModeless(); | |
| 604 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 605 | } catch( final Exception ex ) { | |
| 606 | clue( ex ); | |
| 607 | } | |
| 608 | } | |
| 609 | ||
| 610 | private void viewTab( | |
| 611 | final Node node, final MediaType mediaType, final String key ) { | |
| 612 | final var tabPane = obtainTabPane( mediaType ); | |
| 613 | ||
| 614 | for( final var tab : tabPane.getTabs() ) { | |
| 615 | if( tab.getContent() == node ) { | |
| 616 | return; | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 621 | addTabPane( tabPane ); | |
| 622 | } | |
| 623 | ||
| 624 | public void viewRefresh() { | |
| 625 | mPreview.refresh(); | |
| 626 | } | |
| 627 | ||
| 628 | /** | |
| 629 | * Returns the tab that contains the given {@link TextEditor}. | |
| 630 | * | |
| 631 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 632 | * @return The first tab having content that matches the given tab. | |
| 633 | */ | |
| 634 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 635 | return mTabPanes.stream() | |
| 636 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 637 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 638 | .findFirst(); | |
| 639 | } | |
| 640 | ||
| 641 | /** | |
| 642 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 643 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 644 | * Upon changing, the variables are interpolated and the active text editor | |
| 645 | * is refreshed. | |
| 646 | * | |
| 647 | * @param textEditor Text editor to update with the revised resolved map. | |
| 648 | * @return A newly configured property that represents the active | |
| 649 | * {@link DefinitionEditor}, never null. | |
| 650 | */ | |
| 651 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 652 | final ObjectProperty<TextEditor> textEditor ) { | |
| 653 | final var defEditor = new SimpleObjectProperty<>( | |
| 654 | createDefinitionEditor() | |
| 655 | ); | |
| 656 | ||
| 657 | defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) ); | |
| 658 | ||
| 659 | return defEditor; | |
| 660 | } | |
| 661 | ||
| 662 | private Tab createTab( final String filename, final Node node ) { | |
| 663 | return new DetachableTab( filename, node ); | |
| 664 | } | |
| 665 | ||
| 666 | private Tab createTab( final File file ) { | |
| 667 | final var r = createTextResource( file ); | |
| 668 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 669 | ||
| 670 | r.modifiedProperty().addListener( | |
| 671 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 672 | ); | |
| 673 | ||
| 674 | // This is called when either the tab is closed by the user clicking on | |
| 675 | // the tab's close icon or when closing (all) from the file menu. | |
| 676 | tab.setOnClosed( | |
| 677 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 678 | ); | |
| 679 | ||
| 680 | // When closing a tab, give focus to the newly revealed tab. | |
| 681 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 682 | if( n != null && n ) { | |
| 683 | final var pane = tab.getTabPane(); | |
| 684 | ||
| 685 | if( pane != null ) { | |
| 686 | pane.requestFocus(); | |
| 687 | } | |
| 688 | } | |
| 689 | } ); | |
| 690 | ||
| 691 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 692 | if( nPane != null ) { | |
| 693 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 694 | if( n != null && n ) { | |
| 695 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 696 | final var node = selected.getContent(); | |
| 697 | node.requestFocus(); | |
| 698 | } | |
| 699 | } ); | |
| 700 | } | |
| 701 | } ); | |
| 702 | ||
| 703 | return tab; | |
| 704 | } | |
| 705 | ||
| 706 | /** | |
| 707 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 708 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 709 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 710 | * be replaced by such a class. | |
| 711 | * <p> | |
| 712 | * When binning the files, this makes sure that at least one file exists | |
| 713 | * for every type. If the user has opted to close a particular type (such | |
| 714 | * as the definition pane), the view will suppressed elsewhere. | |
| 715 | * </p> | |
| 716 | * <p> | |
| 717 | * The order that the binned files are returned will be reflected in the | |
| 718 | * order that the corresponding panes are rendered in the UI. | |
| 719 | * </p> | |
| 720 | * | |
| 721 | * @param paths The file paths to bin according to their type. | |
| 722 | * @return An in-order list of files, first by structured definition files, | |
| 723 | * then by plain text documents. | |
| 724 | */ | |
| 725 | private List<File> collect( final SetProperty<String> paths ) { | |
| 726 | // Treat all files destined for the text editor as plain text documents | |
| 727 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 728 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 729 | final Function<MediaType, MediaType> bin = | |
| 730 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 731 | ||
| 732 | // Create two groups: YAML files and plain text files. | |
| 733 | final var bins = paths | |
| 734 | .stream() | |
| 735 | .collect( | |
| 736 | groupingBy( path -> bin.apply( MediaType.fromFilename( path ) ) ) | |
| 737 | ); | |
| 738 | ||
| 739 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 740 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 741 | ||
| 742 | final var result = new ArrayList<File>( paths.size() ); | |
| 743 | ||
| 744 | // Ensure that the same types are listed together (keep insertion order). | |
| 745 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 746 | files.stream().map( File::new ).collect( Collectors.toList() ) ) | |
| 747 | ); | |
| 748 | ||
| 749 | return result; | |
| 750 | } | |
| 751 | ||
| 752 | /** | |
| 753 | * Force the active editor to update, which will cause the processor | |
| 754 | * to re-evaluate the interpolated definition map thereby updating the | |
| 755 | * preview pane. | |
| 756 | * | |
| 757 | * @param editor Contains the source document to update in the preview pane. | |
| 758 | */ | |
| 759 | private void process( final TextEditor editor ) { | |
| 760 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 761 | // text editor immediately for caret movement. The preview will have a | |
| 762 | // slight delay when catching up to the caret position. | |
| 763 | final var task = new Task<Void>() { | |
| 764 | @Override | |
| 765 | public Void call() { | |
| 766 | try { | |
| 767 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 768 | p.apply( editor == null ? "" : editor.getText() ); | |
| 769 | } catch( final Exception ex ) { | |
| 770 | clue( ex ); | |
| 771 | } | |
| 772 | ||
| 773 | return null; | |
| 774 | } | |
| 775 | }; | |
| 776 | ||
| 777 | task.setOnSucceeded( | |
| 778 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 779 | ); | |
| 780 | ||
| 781 | // Prevents multiple process requests from executing simultaneously (due | |
| 782 | // to having a restricted queue size). | |
| 783 | sExecutor.execute( task ); | |
| 784 | } | |
| 785 | ||
| 786 | /** | |
| 787 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 788 | * events. The tab pane is associated with a given media type so that | |
| 789 | * similar files can be grouped together. | |
| 790 | * | |
| 791 | * @param mediaType The media type to associate with the tab pane. | |
| 792 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 793 | */ | |
| 794 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 795 | for( final var pane : mTabPanes ) { | |
| 796 | for( final var tab : pane.getTabs() ) { | |
| 797 | final var node = tab.getContent(); | |
| 798 | ||
| 799 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 800 | return pane; | |
| 801 | } | |
| 802 | } | |
| 803 | } | |
| 804 | ||
| 805 | final var pane = createTabPane(); | |
| 806 | mTabPanes.add( pane ); | |
| 807 | return pane; | |
| 808 | } | |
| 809 | ||
| 810 | /** | |
| 811 | * Creates an initialized {@link TabPane} instance. | |
| 812 | * | |
| 813 | * @return A new {@link TabPane} with all listeners configured. | |
| 814 | */ | |
| 815 | private TabPane createTabPane() { | |
| 816 | final var tabPane = new DetachableTabPane(); | |
| 817 | ||
| 818 | initStageOwnerFactory( tabPane ); | |
| 819 | initTabListener( tabPane ); | |
| 820 | ||
| 821 | return tabPane; | |
| 822 | } | |
| 823 | ||
| 824 | /** | |
| 825 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 826 | * the stage owner factory must be given its parent window, which will | |
| 827 | * own the child window. The parent window is the {@link MainPane}'s | |
| 828 | * {@link Scene}'s {@link Window} instance. | |
| 829 | * | |
| 830 | * <p> | |
| 831 | * This will derives the new title from the main window title, incrementing | |
| 832 | * the window count to help uniquely identify the child windows. | |
| 833 | * </p> | |
| 834 | * | |
| 835 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 836 | */ | |
| 837 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 838 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 839 | final var title = get( | |
| 840 | "Detach.tab.title", | |
| 841 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 842 | ); | |
| 843 | stage.setTitle( title ); | |
| 844 | ||
| 845 | return getScene().getWindow(); | |
| 846 | } ); | |
| 847 | } | |
| 848 | ||
| 849 | /** | |
| 850 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 851 | * it is added to the given {@link DetachableTabPane} instance. | |
| 852 | * <p> | |
| 853 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 854 | * is initialized to perform synchronized scrolling between the editor and | |
| 855 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 856 | * tabs is given focus. | |
| 857 | * </p> | |
| 858 | * <p> | |
| 859 | * Note that multiple tabs can be added simultaneously. | |
| 860 | * </p> | |
| 861 | * | |
| 862 | * @param tabPane A new {@link TabPane} to configure. | |
| 863 | */ | |
| 864 | private void initTabListener( final TabPane tabPane ) { | |
| 865 | tabPane.getTabs().addListener( | |
| 866 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 867 | while( listener.next() ) { | |
| 868 | if( listener.wasAdded() ) { | |
| 869 | final var tabs = listener.getAddedSubList(); | |
| 870 | ||
| 871 | tabs.forEach( ( tab ) -> { | |
| 872 | final var node = tab.getContent(); | |
| 873 | ||
| 874 | if( node instanceof TextEditor ) { | |
| 875 | initScrollEventListener( tab ); | |
| 876 | } | |
| 877 | } ); | |
| 878 | ||
| 879 | // Select and give focus to the last tab opened. | |
| 880 | final var index = tabs.size() - 1; | |
| 881 | if( index >= 0 ) { | |
| 882 | final var tab = tabs.get( index ); | |
| 883 | tabPane.getSelectionModel().select( tab ); | |
| 884 | tab.getContent().requestFocus(); | |
| 885 | } | |
| 886 | } | |
| 887 | } | |
| 888 | } | |
| 889 | ); | |
| 890 | } | |
| 891 | ||
| 892 | /** | |
| 893 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 894 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 895 | * | |
| 896 | * @param tab The container for an instance of {@link TextEditor}. | |
| 897 | */ | |
| 898 | private void initScrollEventListener( final Tab tab ) { | |
| 899 | final var editor = (TextEditor) tab.getContent(); | |
| 900 | final var scrollPane = editor.getScrollPane(); | |
| 901 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 902 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 903 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 904 | } | |
| 905 | ||
| 906 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 907 | final var items = getItems(); | |
| 908 | if( !items.contains( tabPane ) ) { | |
| 909 | items.add( index, tabPane ); | |
| 910 | } | |
| 911 | } | |
| 912 | ||
| 913 | private void addTabPane( final TabPane tabPane ) { | |
| 914 | addTabPane( getItems().size(), tabPane ); | |
| 915 | } | |
| 916 | ||
| 917 | public ProcessorContext createProcessorContext() { | |
| 918 | return createProcessorContext( null, NONE ); | |
| 919 | } | |
| 920 | ||
| 921 | public ProcessorContext createProcessorContext( | |
| 922 | final Path exportPath, final ExportFormat format ) { | |
| 923 | final var textEditor = getActiveTextEditor(); | |
| 924 | return createProcessorContext( | |
| 925 | textEditor.getPath(), exportPath, format, textEditor.getCaret() ); | |
| 926 | } | |
| 927 | ||
| 928 | private ProcessorContext createProcessorContext( | |
| 929 | final Path inputPath, final Caret caret ) { | |
| 930 | return createProcessorContext( inputPath, null, NONE, caret ); | |
| 931 | } | |
| 932 | ||
| 933 | /** | |
| 934 | * @param inputPath Used by {@link ProcessorFactory} to determine | |
| 935 | * {@link Processor} type to create based on file type. | |
| 936 | * @param outputPath Used when exporting to a PDF file (binary). | |
| 937 | * @param format Used when processors export to a new text format. | |
| 938 | * @param caret Used by {@link CaretExtension} to add ID attribute into | |
| 939 | * preview document for scrollbar synchronization. | |
| 940 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 941 | * {@link Processor}. | |
| 942 | */ | |
| 943 | private ProcessorContext createProcessorContext( | |
| 944 | final Path inputPath, | |
| 945 | final Path outputPath, | |
| 946 | final ExportFormat format, | |
| 947 | final Caret caret ) { | |
| 948 | return ProcessorContext.builder() | |
| 949 | .with( ProcessorContext.Mutator::setInputPath, inputPath ) | |
| 950 | .with( ProcessorContext.Mutator::setOutputPath, outputPath ) | |
| 951 | .with( ProcessorContext.Mutator::setExportFormat, format ) | |
| 952 | .with( ProcessorContext.Mutator::setHtmlPreview, mPreview ) | |
| 953 | .with( ProcessorContext.Mutator::setTextDefinition, mActiveDefinitionEditor ) | |
| 954 | .with( ProcessorContext.Mutator::setWorkspace, mWorkspace ) | |
| 955 | .with( ProcessorContext.Mutator::setCaret, caret ) | |
| 956 | .build(); | |
| 957 | } | |
| 958 | ||
| 959 | private TextResource createTextResource( final File file ) { | |
| 960 | // TODO: Create PlainTextEditor that's returned by default. | |
| 961 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 962 | ? createDefinitionEditor( file ) | |
| 963 | : createMarkdownEditor( file ); | |
| 964 | } | |
| 965 | ||
| 966 | /** | |
| 967 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 968 | * caret change events and text change events. Text change events must | |
| 969 | * take priority over caret change events because it's possible to change | |
| 970 | * the text without moving the caret (e.g., delete selected text). | |
| 971 | * | |
| 972 | * @param inputFile The file containing contents for the text editor. | |
| 973 | * @return A non-null text editor. | |
| 974 | */ | |
| 975 | private TextResource createMarkdownEditor( final File inputFile ) { | |
| 976 | final var inputPath = inputFile.toPath(); | |
| 977 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 978 | final var caret = editor.getCaret(); | |
| 979 | final var context = createProcessorContext( inputPath, caret ); | |
| 980 | ||
| 981 | mProcessors.computeIfAbsent( editor, p -> createProcessors( context ) ); | |
| 982 | ||
| 983 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 984 | if( n ) { | |
| 985 | // Reset the status to OK after changing the text. | |
| 986 | clue(); | |
| 987 | ||
| 988 | // Processing the text may update the status bar. | |
| 989 | process( getActiveTextEditor() ); | |
| 990 | } | |
| 991 | } ); | |
| 992 | ||
| 993 | editor.addEventListener( | |
| 994 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 995 | ); | |
| 996 | ||
| 997 | // Set the active editor, which refreshes the preview panel. | |
| 998 | mActiveTextEditor.set( editor ); | |
| 999 | ||
| 1000 | return editor; | |
| 1001 | } | |
| 1002 | ||
| 1003 | /** | |
| 1004 | * Delegates to {@link #autoinsert()}. | |
| 1005 | * | |
| 1006 | * @param event Ignored. | |
| 1007 | */ | |
| 1008 | private void autoinsert( final KeyEvent event ) { | |
| 1009 | autoinsert(); | |
| 1010 | } | |
| 1011 | ||
| 1012 | /** | |
| 1013 | * Finds a node that matches the word at the caret, then inserts the | |
| 1014 | * corresponding definition. The definition token delimiters depend on | |
| 1015 | * the type of file being edited. | |
| 1016 | */ | |
| 1017 | public void autoinsert() { | |
| 1018 | final var definitions = getActiveTextDefinition(); | |
| 1019 | final var editor = getActiveTextEditor(); | |
| 1020 | final var mediaType = editor.getMediaType(); | |
| 1021 | final var operator = getSigilOperator( mediaType ); | |
| 1022 | ||
| 1023 | DefinitionNameInjector.autoinsert( editor, definitions, operator ); | |
| 1024 | } | |
| 1025 | ||
| 1026 | private TextDefinition createDefinitionEditor() { | |
| 1027 | return createDefinitionEditor( DEFINITION_DEFAULT ); | |
| 1028 | } | |
| 1029 | ||
| 1030 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 1031 | final var editor = new DefinitionEditor( | |
| 1032 | file, createTreeTransformer(), createYamlSigilOperator() ); | |
| 1033 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 1034 | return editor; | |
| 1035 | } | |
| 1036 | ||
| 1037 | private TreeTransformer createTreeTransformer() { | |
| 1038 | return new YamlTreeTransformer(); | |
| 1039 | } | |
| 1040 | ||
| 1041 | private Tooltip createTooltip( final File file ) { | |
| 1042 | final var path = file.toPath(); | |
| 1043 | final var tooltip = new Tooltip( path.toString() ); | |
| 1044 | ||
| 1045 | tooltip.setShowDelay( millis( 200 ) ); | |
| 1046 | return tooltip; | |
| 1047 | } | |
| 1048 | ||
| 1049 | public TextEditor getActiveTextEditor() { | |
| 1050 | return mActiveTextEditor.get(); | |
| 1051 | } | |
| 1052 | ||
| 1053 | public ReadOnlyObjectProperty<TextEditor> activeTextEditorProperty() { | |
| 1054 | return mActiveTextEditor; | |
| 1055 | } | |
| 1056 | ||
| 1057 | public TextDefinition getActiveTextDefinition() { | |
| 1058 | return mActiveDefinitionEditor.get(); | |
| 1059 | } | |
| 1060 | ||
| 1061 | public Window getWindow() { | |
| 1062 | return getScene().getWindow(); | |
| 1063 | } | |
| 1064 | ||
| 1065 | public Workspace getWorkspace() { | |
| 1066 | return mWorkspace; | |
| 1067 | } | |
| 1068 | ||
| 1069 | /** | |
| 1070 | * Returns the sigil operator for the given {@link MediaType}. | |
| 1071 | * | |
| 1072 | * @param mediaType The type of file being edited. | |
| 1073 | */ | |
| 1074 | private SigilOperator getSigilOperator( final MediaType mediaType ) { | |
| 1075 | final var operator = new YamlSigilOperator( createDefinitionSigils() ); | |
| 1076 | ||
| 1077 | return mediaType == TEXT_R_MARKDOWN | |
| 1078 | ? new RSigilOperator( createRSigils(), operator ) | |
| 1079 | : operator; | |
| 1080 | } | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * Returns the set of file names opened in the application. The names must | |
| 1084 | * be converted to {@link File} objects. | |
| 1085 | * | |
| 1086 | * @return A {@link Set} of file names. | |
| 1087 | */ | |
| 1088 | private SetProperty<String> getRecentFiles() { | |
| 1089 | return getWorkspace().setsProperty( KEY_UI_FILES_PATH ); | |
| 1090 | } | |
| 1091 | ||
| 1092 | private StringProperty stringProperty( final Key key ) { | |
| 1093 | return getWorkspace().stringProperty( key ); | |
| 1094 | } | |
| 1095 | ||
| 1096 | private SigilOperator createYamlSigilOperator() { | |
| 1097 | return new YamlSigilOperator( createDefinitionSigils() ); | |
| 1098 | } | |
| 1099 | ||
| 1100 | private Sigils createRSigils() { | |
| 1101 | return createSigils( KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ); | |
| 1102 | } | |
| 1103 | ||
| 1104 | private Sigils createDefinitionSigils() { | |
| 1105 | return createSigils( KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED ); | |
| 1106 | } | |
| 1107 | ||
| 1108 | private Sigils createSigils( final Key began, final Key ended ) { | |
| 1109 | return new Sigils( stringProperty( began ), stringProperty( ended ) ); | |
| 16 | import com.keenwrite.processors.HtmlPreviewProcessor; | |
| 17 | import com.keenwrite.processors.Processor; | |
| 18 | import com.keenwrite.processors.ProcessorContext; | |
| 19 | import com.keenwrite.processors.ProcessorFactory; | |
| 20 | import com.keenwrite.processors.r.InlineRProcessor; | |
| 21 | import com.keenwrite.service.events.Notifier; | |
| 22 | import com.keenwrite.sigils.PropertyKeyOperator; | |
| 23 | import com.keenwrite.sigils.RKeyOperator; | |
| 24 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 25 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 26 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 27 | import com.keenwrite.util.GenericBuilder; | |
| 28 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 29 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 30 | import javafx.application.Platform; | |
| 31 | import javafx.beans.property.*; | |
| 32 | import javafx.collections.ListChangeListener; | |
| 33 | import javafx.concurrent.Task; | |
| 34 | import javafx.event.ActionEvent; | |
| 35 | import javafx.event.Event; | |
| 36 | import javafx.event.EventHandler; | |
| 37 | import javafx.scene.Node; | |
| 38 | import javafx.scene.Scene; | |
| 39 | import javafx.scene.control.*; | |
| 40 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 41 | import javafx.scene.input.KeyEvent; | |
| 42 | import javafx.scene.layout.FlowPane; | |
| 43 | import javafx.stage.Stage; | |
| 44 | import javafx.stage.Window; | |
| 45 | import org.greenrobot.eventbus.Subscribe; | |
| 46 | ||
| 47 | import java.io.File; | |
| 48 | import java.io.FileNotFoundException; | |
| 49 | import java.nio.file.Path; | |
| 50 | import java.util.*; | |
| 51 | import java.util.concurrent.ExecutorService; | |
| 52 | import java.util.concurrent.ScheduledExecutorService; | |
| 53 | import java.util.concurrent.ScheduledFuture; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.concurrent.atomic.AtomicReference; | |
| 56 | import java.util.function.Function; | |
| 57 | import java.util.function.UnaryOperator; | |
| 58 | import java.util.stream.Collectors; | |
| 59 | ||
| 60 | import static com.keenwrite.ExportFormat.NONE; | |
| 61 | import static com.keenwrite.Messages.get; | |
| 62 | import static com.keenwrite.constants.Constants.*; | |
| 63 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | |
| 64 | import static com.keenwrite.events.Bus.register; | |
| 65 | import static com.keenwrite.events.StatusEvent.clue; | |
| 66 | import static com.keenwrite.io.MediaType.*; | |
| 67 | import static com.keenwrite.preferences.AppKeys.*; | |
| 68 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 69 | import static com.keenwrite.processors.ProcessorContext.Mutator; | |
| 70 | import static com.keenwrite.processors.ProcessorContext.builder; | |
| 71 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 72 | import static java.lang.String.format; | |
| 73 | import static java.lang.System.getProperty; | |
| 74 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 75 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 76 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 77 | import static java.util.stream.Collectors.groupingBy; | |
| 78 | import static javafx.application.Platform.runLater; | |
| 79 | import static javafx.scene.control.Alert.AlertType.ERROR; | |
| 80 | import static javafx.scene.control.ButtonType.*; | |
| 81 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 82 | import static javafx.scene.input.KeyCode.SPACE; | |
| 83 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 84 | import static javafx.util.Duration.millis; | |
| 85 | import static javax.swing.SwingUtilities.invokeLater; | |
| 86 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 87 | ||
| 88 | /** | |
| 89 | * Responsible for wiring together the main application components for a | |
| 90 | * particular {@link Workspace} (project). These include the definition views, | |
| 91 | * text editors, and preview pane along with any corresponding controllers. | |
| 92 | */ | |
| 93 | public final class MainPane extends SplitPane { | |
| 94 | ||
| 95 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 96 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 97 | ||
| 98 | /** | |
| 99 | * Used when opening files to determine how each file should be binned and | |
| 100 | * therefore what tab pane to be opened within. | |
| 101 | */ | |
| 102 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 103 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 104 | ); | |
| 105 | ||
| 106 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 107 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 108 | new AtomicReference<>(); | |
| 109 | ||
| 110 | /** | |
| 111 | * Prevents re-instantiation of processing classes. | |
| 112 | */ | |
| 113 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 114 | new HashMap<>(); | |
| 115 | ||
| 116 | private final Workspace mWorkspace; | |
| 117 | ||
| 118 | /** | |
| 119 | * Groups similar file type tabs together. | |
| 120 | */ | |
| 121 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Renders the actively selected plain text editor tab. | |
| 125 | */ | |
| 126 | private final HtmlPreview mPreview; | |
| 127 | ||
| 128 | /** | |
| 129 | * Provides an interactive document outline. | |
| 130 | */ | |
| 131 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 132 | ||
| 133 | /** | |
| 134 | * Changing the active editor fires the value changed event. This allows | |
| 135 | * refreshes to happen when external definitions are modified and need to | |
| 136 | * trigger the processing chain. | |
| 137 | */ | |
| 138 | private final ObjectProperty<TextEditor> mTextEditor = | |
| 139 | createActiveTextEditor(); | |
| 140 | ||
| 141 | /** | |
| 142 | * Changing the active definition editor fires the value changed event. This | |
| 143 | * allows refreshes to happen when external definitions are modified and need | |
| 144 | * to trigger the processing chain. | |
| 145 | */ | |
| 146 | private final ObjectProperty<TextDefinition> mDefinitionEditor; | |
| 147 | ||
| 148 | /** | |
| 149 | * Called when the definition data is changed. | |
| 150 | */ | |
| 151 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 152 | event -> { | |
| 153 | process( getTextEditor() ); | |
| 154 | save( getTextDefinition() ); | |
| 155 | }; | |
| 156 | ||
| 157 | /** | |
| 158 | * Tracks the number of detached tab panels opened into their own windows, | |
| 159 | * which allows unique identification of subordinate windows by their title. | |
| 160 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 161 | */ | |
| 162 | private byte mWindowCount; | |
| 163 | ||
| 164 | private final DocumentStatistics mStatistics; | |
| 165 | ||
| 166 | /** | |
| 167 | * Adds all content panels to the main user interface. This will load the | |
| 168 | * configuration settings from the workspace to reproduce the settings from | |
| 169 | * a previous session. | |
| 170 | */ | |
| 171 | public MainPane( final Workspace workspace ) { | |
| 172 | mWorkspace = workspace; | |
| 173 | mPreview = new HtmlPreview( workspace ); | |
| 174 | mStatistics = new DocumentStatistics( workspace ); | |
| 175 | mTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 176 | mDefinitionEditor = createActiveDefinitionEditor( mTextEditor ); | |
| 177 | ||
| 178 | open( collect( getRecentFiles() ) ); | |
| 179 | viewPreview(); | |
| 180 | setDividerPositions( calculateDividerPositions() ); | |
| 181 | ||
| 182 | // Once the main scene's window regains focus, update the active definition | |
| 183 | // editor to the currently selected tab. | |
| 184 | runLater( () -> getWindow().setOnCloseRequest( event -> { | |
| 185 | // Order matters: Open file names must be persisted before closing all. | |
| 186 | mWorkspace.save(); | |
| 187 | ||
| 188 | if( closeAll() ) { | |
| 189 | Platform.exit(); | |
| 190 | System.exit( 0 ); | |
| 191 | } | |
| 192 | ||
| 193 | event.consume(); | |
| 194 | } ) ); | |
| 195 | ||
| 196 | register( this ); | |
| 197 | initAutosave( workspace ); | |
| 198 | } | |
| 199 | ||
| 200 | @Subscribe | |
| 201 | public void handle( final TextEditorFocusEvent event ) { | |
| 202 | mTextEditor.set( event.get() ); | |
| 203 | } | |
| 204 | ||
| 205 | @Subscribe | |
| 206 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 207 | mDefinitionEditor.set( event.get() ); | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Typically called when a file name is clicked in the preview panel. | |
| 212 | * | |
| 213 | * @param event The event to process, must contain a valid file reference. | |
| 214 | */ | |
| 215 | @Subscribe | |
| 216 | public void handle( final FileOpenEvent event ) { | |
| 217 | final File eventFile; | |
| 218 | final var eventUri = event.getUri(); | |
| 219 | ||
| 220 | if( eventUri.isAbsolute() ) { | |
| 221 | eventFile = new File( eventUri.getPath() ); | |
| 222 | } | |
| 223 | else { | |
| 224 | final var activeFile = getTextEditor().getFile(); | |
| 225 | final var parent = activeFile.getParentFile(); | |
| 226 | ||
| 227 | if( parent == null ) { | |
| 228 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 229 | return; | |
| 230 | } | |
| 231 | else { | |
| 232 | final var parentPath = parent.getAbsolutePath(); | |
| 233 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | runLater( () -> open( eventFile ) ); | |
| 238 | } | |
| 239 | ||
| 240 | @Subscribe | |
| 241 | public void handle( final CaretNavigationEvent event ) { | |
| 242 | runLater( () -> { | |
| 243 | final var textArea = getTextEditor().getTextArea(); | |
| 244 | textArea.moveTo( event.getOffset() ); | |
| 245 | textArea.requestFollowCaret(); | |
| 246 | textArea.requestFocus(); | |
| 247 | } ); | |
| 248 | } | |
| 249 | ||
| 250 | @Subscribe | |
| 251 | @SuppressWarnings( "unused" ) | |
| 252 | public void handle( final ExportFailedEvent event ) { | |
| 253 | final var os = getProperty( "os.name" ); | |
| 254 | final var arch = getProperty( "os.arch" ).toLowerCase(); | |
| 255 | final var bits = getProperty( "sun.arch.data.model" ); | |
| 256 | ||
| 257 | final var title = Messages.get( "Alert.typesetter.missing.title" ); | |
| 258 | final var header = Messages.get( "Alert.typesetter.missing.header" ); | |
| 259 | final var version = Messages.get( | |
| 260 | "Alert.typesetter.missing.version", | |
| 261 | os, | |
| 262 | arch | |
| 263 | .replaceAll( "amd.*|i.*|x86.*", "X86" ) | |
| 264 | .replaceAll( "mips.*", "MIPS" ) | |
| 265 | .replaceAll( "armv.*", "ARM" ), | |
| 266 | bits ); | |
| 267 | final var text = Messages.get( "Alert.typesetter.missing.installer.text" ); | |
| 268 | ||
| 269 | // Download and install ConTeXt for {0} {1} {2}-bit | |
| 270 | final var content = format( "%s %s", text, version ); | |
| 271 | final var flowPane = new FlowPane(); | |
| 272 | final var link = new Hyperlink( text ); | |
| 273 | final var label = new Label( version ); | |
| 274 | flowPane.getChildren().addAll( link, label ); | |
| 275 | ||
| 276 | final var alert = new Alert( ERROR, content, OK ); | |
| 277 | alert.setTitle( title ); | |
| 278 | alert.setHeaderText( header ); | |
| 279 | alert.getDialogPane().contentProperty().set( flowPane ); | |
| 280 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 281 | ||
| 282 | link.setOnAction( ( e ) -> { | |
| 283 | alert.close(); | |
| 284 | final var url = Messages.get( "Alert.typesetter.missing.installer.url" ); | |
| 285 | runLater( () -> HyperlinkOpenEvent.fire( url ) ); | |
| 286 | } ); | |
| 287 | ||
| 288 | alert.showAndWait(); | |
| 289 | } | |
| 290 | ||
| 291 | private void initAutosave( final Workspace workspace ) { | |
| 292 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 293 | ||
| 294 | rate.addListener( | |
| 295 | ( c, o, n ) -> { | |
| 296 | final var taskRef = mSaveTask.get(); | |
| 297 | ||
| 298 | // Prevent multiple autosaves from running. | |
| 299 | if( taskRef != null ) { | |
| 300 | taskRef.cancel( false ); | |
| 301 | } | |
| 302 | ||
| 303 | initAutosave( rate ); | |
| 304 | } | |
| 305 | ); | |
| 306 | ||
| 307 | // Start the save listener (avoids duplicating some code). | |
| 308 | initAutosave( rate ); | |
| 309 | } | |
| 310 | ||
| 311 | private void initAutosave( final IntegerProperty rate ) { | |
| 312 | mSaveTask.set( | |
| 313 | mSaver.scheduleAtFixedRate( | |
| 314 | () -> { | |
| 315 | if( getTextEditor().isModified() ) { | |
| 316 | // Ensure the modified indicator is cleared by running on EDT. | |
| 317 | runLater( this::save ); | |
| 318 | } | |
| 319 | }, 0, rate.intValue(), SECONDS | |
| 320 | ) | |
| 321 | ); | |
| 322 | } | |
| 323 | ||
| 324 | /** | |
| 325 | * TODO: Load divider positions from exported settings, see | |
| 326 | * {@link #collect(SetProperty)} comment. | |
| 327 | */ | |
| 328 | private double[] calculateDividerPositions() { | |
| 329 | final var ratio = 100f / getItems().size() / 100; | |
| 330 | final var positions = getDividerPositions(); | |
| 331 | ||
| 332 | for( int i = 0; i < positions.length; i++ ) { | |
| 333 | positions[ i ] = ratio * i; | |
| 334 | } | |
| 335 | ||
| 336 | return positions; | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * Opens all the files into the application, provided the paths are unique. | |
| 341 | * This may only be called for any type of files that a user can edit | |
| 342 | * (i.e., update and persist), such as definitions and text files. | |
| 343 | * | |
| 344 | * @param files The list of files to open. | |
| 345 | */ | |
| 346 | public void open( final List<File> files ) { | |
| 347 | files.forEach( this::open ); | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * This opens the given file. Since the preview pane is not a file that | |
| 352 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 353 | * This will exit early if the given file is not a regular file (i.e., a | |
| 354 | * directory). | |
| 355 | * | |
| 356 | * @param inputFile The file to open. | |
| 357 | */ | |
| 358 | private void open( final File inputFile ) { | |
| 359 | // Prevent opening directories (a non-existent "untitled.md" is fine). | |
| 360 | if( !inputFile.isFile() && inputFile.exists() ) { | |
| 361 | return; | |
| 362 | } | |
| 363 | ||
| 364 | final var tab = createTab( inputFile ); | |
| 365 | final var node = tab.getContent(); | |
| 366 | final var mediaType = MediaType.valueFrom( inputFile ); | |
| 367 | final var tabPane = obtainTabPane( mediaType ); | |
| 368 | ||
| 369 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 370 | tabPane.setFocusTraversable( false ); | |
| 371 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 372 | tabPane.getTabs().add( tab ); | |
| 373 | ||
| 374 | // Attach the tab scene factory for new tab panes. | |
| 375 | if( !getItems().contains( tabPane ) ) { | |
| 376 | addTabPane( | |
| 377 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 378 | ); | |
| 379 | } | |
| 380 | ||
| 381 | if( inputFile.isFile() ) { | |
| 382 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 383 | } | |
| 384 | } | |
| 385 | ||
| 386 | /** | |
| 387 | * Opens a new text editor document using the default document file name. | |
| 388 | */ | |
| 389 | public void newTextEditor() { | |
| 390 | open( DOCUMENT_DEFAULT ); | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Opens a new definition editor document using the default definition | |
| 395 | * file name. | |
| 396 | */ | |
| 397 | public void newDefinitionEditor() { | |
| 398 | open( DEFINITION_DEFAULT ); | |
| 399 | } | |
| 400 | ||
| 401 | /** | |
| 402 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 403 | * that they save themselves. | |
| 404 | */ | |
| 405 | public void saveAll() { | |
| 406 | mTabPanes.forEach( | |
| 407 | tp -> tp.getTabs().forEach( tab -> { | |
| 408 | final var node = tab.getContent(); | |
| 409 | ||
| 410 | if( node instanceof final TextEditor editor ) { | |
| 411 | save( editor ); | |
| 412 | } | |
| 413 | } ) | |
| 414 | ); | |
| 415 | } | |
| 416 | ||
| 417 | /** | |
| 418 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 419 | * checking if modified first because if the user swaps external media from | |
| 420 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 421 | * the user: save always re-saves. Also, it's less code. | |
| 422 | */ | |
| 423 | public void save() { | |
| 424 | save( getTextEditor() ); | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Saves the active {@link TextEditor} under a new name. | |
| 429 | * | |
| 430 | * @param files The new active editor {@link File} reference, must contain | |
| 431 | * at least one element. | |
| 432 | */ | |
| 433 | public void saveAs( final List<File> files ) { | |
| 434 | assert files != null; | |
| 435 | assert !files.isEmpty(); | |
| 436 | final var editor = getTextEditor(); | |
| 437 | final var tab = getTab( editor ); | |
| 438 | final var file = files.get( 0 ); | |
| 439 | ||
| 440 | editor.rename( file ); | |
| 441 | tab.ifPresent( t -> { | |
| 442 | t.setText( editor.getFilename() ); | |
| 443 | t.setTooltip( createTooltip( file ) ); | |
| 444 | } ); | |
| 445 | ||
| 446 | save(); | |
| 447 | } | |
| 448 | ||
| 449 | /** | |
| 450 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 451 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 452 | * | |
| 453 | * @param resource The resource to export. | |
| 454 | */ | |
| 455 | private void save( final TextResource resource ) { | |
| 456 | try { | |
| 457 | resource.save(); | |
| 458 | } catch( final Exception ex ) { | |
| 459 | clue( ex ); | |
| 460 | sNotifier.alert( | |
| 461 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 462 | ); | |
| 463 | } | |
| 464 | } | |
| 465 | ||
| 466 | /** | |
| 467 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 468 | * | |
| 469 | * @return {@code true} when all editors, modified or otherwise, were | |
| 470 | * permitted to close; {@code false} when one or more editors were modified | |
| 471 | * and the user requested no closing. | |
| 472 | */ | |
| 473 | public boolean closeAll() { | |
| 474 | var closable = true; | |
| 475 | ||
| 476 | for( final var tabPane : mTabPanes ) { | |
| 477 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 478 | ||
| 479 | while( tabIterator.hasNext() ) { | |
| 480 | final var tab = tabIterator.next(); | |
| 481 | final var resource = tab.getContent(); | |
| 482 | ||
| 483 | // The definition panes auto-save, so being specific here prevents | |
| 484 | // closing the definitions in the situation where the user wants to | |
| 485 | // continue editing (i.e., possibly save unsaved work). | |
| 486 | if( !(resource instanceof TextEditor) ) { | |
| 487 | continue; | |
| 488 | } | |
| 489 | ||
| 490 | if( canClose( (TextEditor) resource ) ) { | |
| 491 | tabIterator.remove(); | |
| 492 | close( tab ); | |
| 493 | } | |
| 494 | else { | |
| 495 | closable = false; | |
| 496 | } | |
| 497 | } | |
| 498 | } | |
| 499 | ||
| 500 | return closable; | |
| 501 | } | |
| 502 | ||
| 503 | /** | |
| 504 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 505 | * event. | |
| 506 | * | |
| 507 | * @param tab The {@link Tab} that was closed. | |
| 508 | */ | |
| 509 | private void close( final Tab tab ) { | |
| 510 | assert tab != null; | |
| 511 | ||
| 512 | final var handler = tab.getOnClosed(); | |
| 513 | ||
| 514 | if( handler != null ) { | |
| 515 | handler.handle( new ActionEvent() ); | |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | /** | |
| 520 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 521 | */ | |
| 522 | public void close() { | |
| 523 | final var editor = getTextEditor(); | |
| 524 | ||
| 525 | if( canClose( editor ) ) { | |
| 526 | close( editor ); | |
| 527 | } | |
| 528 | } | |
| 529 | ||
| 530 | /** | |
| 531 | * Closes the given {@link TextResource}. This must not be called from within | |
| 532 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 533 | * concurrent modification exception be thrown. | |
| 534 | * | |
| 535 | * @param resource The {@link TextResource} to close, without confirming with | |
| 536 | * the user. | |
| 537 | */ | |
| 538 | private void close( final TextResource resource ) { | |
| 539 | getTab( resource ).ifPresent( | |
| 540 | ( tab ) -> { | |
| 541 | close( tab ); | |
| 542 | tab.getTabPane().getTabs().remove( tab ); | |
| 543 | } | |
| 544 | ); | |
| 545 | } | |
| 546 | ||
| 547 | /** | |
| 548 | * Answers whether the given {@link TextResource} may be closed. | |
| 549 | * | |
| 550 | * @param editor The {@link TextResource} to try closing. | |
| 551 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 552 | * the user has requested to keep the editor open. | |
| 553 | */ | |
| 554 | private boolean canClose( final TextResource editor ) { | |
| 555 | final var editorTab = getTab( editor ); | |
| 556 | final var canClose = new AtomicBoolean( true ); | |
| 557 | ||
| 558 | if( editor.isModified() ) { | |
| 559 | final var filename = new StringBuilder(); | |
| 560 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 561 | ||
| 562 | final var message = sNotifier.createNotification( | |
| 563 | Messages.get( "Alert.file.close.title" ), | |
| 564 | Messages.get( "Alert.file.close.text" ), | |
| 565 | filename.toString() | |
| 566 | ); | |
| 567 | ||
| 568 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 569 | ||
| 570 | dialog.showAndWait().ifPresent( | |
| 571 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 572 | ); | |
| 573 | } | |
| 574 | ||
| 575 | return canClose.get(); | |
| 576 | } | |
| 577 | ||
| 578 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 579 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 580 | ||
| 581 | editor.addListener( ( c, o, n ) -> { | |
| 582 | if( n != null ) { | |
| 583 | mPreview.setBaseUri( n.getPath() ); | |
| 584 | process( n ); | |
| 585 | } | |
| 586 | } ); | |
| 587 | ||
| 588 | return editor; | |
| 589 | } | |
| 590 | ||
| 591 | /** | |
| 592 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 593 | */ | |
| 594 | public void viewPreview() { | |
| 595 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 596 | } | |
| 597 | ||
| 598 | /** | |
| 599 | * Adds the document outline tab to its own, singular tab pane. | |
| 600 | */ | |
| 601 | public void viewOutline() { | |
| 602 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 603 | } | |
| 604 | ||
| 605 | public void viewStatistics() { | |
| 606 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 607 | } | |
| 608 | ||
| 609 | public void viewFiles() { | |
| 610 | try { | |
| 611 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 612 | final var fileManager = factory.createModeless(); | |
| 613 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 614 | } catch( final Exception ex ) { | |
| 615 | clue( ex ); | |
| 616 | } | |
| 617 | } | |
| 618 | ||
| 619 | private void viewTab( | |
| 620 | final Node node, final MediaType mediaType, final String key ) { | |
| 621 | final var tabPane = obtainTabPane( mediaType ); | |
| 622 | ||
| 623 | for( final var tab : tabPane.getTabs() ) { | |
| 624 | if( tab.getContent() == node ) { | |
| 625 | return; | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 630 | addTabPane( tabPane ); | |
| 631 | } | |
| 632 | ||
| 633 | public void viewRefresh() { | |
| 634 | mPreview.refresh(); | |
| 635 | } | |
| 636 | ||
| 637 | /** | |
| 638 | * Returns the tab that contains the given {@link TextEditor}. | |
| 639 | * | |
| 640 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 641 | * @return The first tab having content that matches the given tab. | |
| 642 | */ | |
| 643 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 644 | return mTabPanes.stream() | |
| 645 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 646 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 647 | .findFirst(); | |
| 648 | } | |
| 649 | ||
| 650 | /** | |
| 651 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 652 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 653 | * Upon changing, the variables are interpolated and the active text editor | |
| 654 | * is refreshed. | |
| 655 | * | |
| 656 | * @param textEditor Text editor to update with the revised resolved map. | |
| 657 | * @return A newly configured property that represents the active | |
| 658 | * {@link DefinitionEditor}, never null. | |
| 659 | */ | |
| 660 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 661 | final ObjectProperty<TextEditor> textEditor ) { | |
| 662 | final var defEditor = new SimpleObjectProperty<>( | |
| 663 | createDefinitionEditor() | |
| 664 | ); | |
| 665 | ||
| 666 | defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) ); | |
| 667 | ||
| 668 | return defEditor; | |
| 669 | } | |
| 670 | ||
| 671 | private Tab createTab( final String filename, final Node node ) { | |
| 672 | return new DetachableTab( filename, node ); | |
| 673 | } | |
| 674 | ||
| 675 | private Tab createTab( final File file ) { | |
| 676 | final var r = createTextResource( file ); | |
| 677 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 678 | ||
| 679 | r.modifiedProperty().addListener( | |
| 680 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 681 | ); | |
| 682 | ||
| 683 | // This is called when either the tab is closed by the user clicking on | |
| 684 | // the tab's close icon or when closing (all) from the file menu. | |
| 685 | tab.setOnClosed( | |
| 686 | ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 687 | ); | |
| 688 | ||
| 689 | // When closing a tab, give focus to the newly revealed tab. | |
| 690 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 691 | if( n != null && n ) { | |
| 692 | final var pane = tab.getTabPane(); | |
| 693 | ||
| 694 | if( pane != null ) { | |
| 695 | pane.requestFocus(); | |
| 696 | } | |
| 697 | } | |
| 698 | } ); | |
| 699 | ||
| 700 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 701 | if( nPane != null ) { | |
| 702 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 703 | if( n != null && n ) { | |
| 704 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 705 | final var node = selected.getContent(); | |
| 706 | node.requestFocus(); | |
| 707 | } | |
| 708 | } ); | |
| 709 | } | |
| 710 | } ); | |
| 711 | ||
| 712 | return tab; | |
| 713 | } | |
| 714 | ||
| 715 | /** | |
| 716 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 717 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 718 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 719 | * be replaced by such a class. | |
| 720 | * <p> | |
| 721 | * When binning the files, this makes sure that at least one file exists | |
| 722 | * for every type. If the user has opted to close a particular type (such | |
| 723 | * as the definition pane), the view will suppressed elsewhere. | |
| 724 | * </p> | |
| 725 | * <p> | |
| 726 | * The order that the binned files are returned will be reflected in the | |
| 727 | * order that the corresponding panes are rendered in the UI. | |
| 728 | * </p> | |
| 729 | * | |
| 730 | * @param paths The file paths to bin according to their type. | |
| 731 | * @return An in-order list of files, first by structured definition files, | |
| 732 | * then by plain text documents. | |
| 733 | */ | |
| 734 | private List<File> collect( final SetProperty<String> paths ) { | |
| 735 | // Treat all files destined for the text editor as plain text documents | |
| 736 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 737 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 738 | final Function<MediaType, MediaType> bin = | |
| 739 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 740 | ||
| 741 | // Create two groups: YAML files and plain text files. The order that | |
| 742 | // the elements are listed in the enumeration for media types determines | |
| 743 | // what files are loaded first. Variable definitions come before all other | |
| 744 | // plain text documents. | |
| 745 | final var bins = paths | |
| 746 | .stream() | |
| 747 | .collect( | |
| 748 | groupingBy( | |
| 749 | path -> bin.apply( MediaType.fromFilename( path ) ), | |
| 750 | () -> new TreeMap<>( Enum::compareTo ), | |
| 751 | Collectors.toList() | |
| 752 | ) | |
| 753 | ); | |
| 754 | ||
| 755 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 756 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 757 | ||
| 758 | final var result = new LinkedList<File>(); | |
| 759 | ||
| 760 | // Ensure that the same types are listed together (keep insertion order). | |
| 761 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 762 | files.stream().map( File::new ).toList() ) | |
| 763 | ); | |
| 764 | ||
| 765 | return result; | |
| 766 | } | |
| 767 | ||
| 768 | /** | |
| 769 | * Force the active editor to update, which will cause the processor | |
| 770 | * to re-evaluate the interpolated definition map thereby updating the | |
| 771 | * preview pane. | |
| 772 | * | |
| 773 | * @param editor Contains the source document to update in the preview pane. | |
| 774 | */ | |
| 775 | private void process( final TextEditor editor ) { | |
| 776 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 777 | // text editor immediately for caret movement. The preview will have a | |
| 778 | // slight delay when catching up to the caret position. | |
| 779 | final var task = new Task<Void>() { | |
| 780 | @Override | |
| 781 | public Void call() { | |
| 782 | try { | |
| 783 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 784 | p.apply( editor == null ? "" : editor.getText() ); | |
| 785 | } catch( final Exception ex ) { | |
| 786 | clue( ex ); | |
| 787 | } | |
| 788 | ||
| 789 | return null; | |
| 790 | } | |
| 791 | }; | |
| 792 | ||
| 793 | // TODO: Each time the editor successfully runs the processor the task is | |
| 794 | // considered successful. Due to the rapid-fire nature of processing | |
| 795 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 796 | // scroll each time. | |
| 797 | // The algorithm: | |
| 798 | // 1. Peek at the oldest time. | |
| 799 | // 2. If the difference between the oldest time and current time exceeds | |
| 800 | // 250 milliseconds, then invoke the scrolling. | |
| 801 | // 3. Insert the current time into the circular queue. | |
| 802 | task.setOnSucceeded( | |
| 803 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 804 | ); | |
| 805 | ||
| 806 | // Prevents multiple process requests from executing simultaneously (due | |
| 807 | // to having a restricted queue size). | |
| 808 | sExecutor.execute( task ); | |
| 809 | } | |
| 810 | ||
| 811 | /** | |
| 812 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 813 | * events. The tab pane is associated with a given media type so that | |
| 814 | * similar files can be grouped together. | |
| 815 | * | |
| 816 | * @param mediaType The media type to associate with the tab pane. | |
| 817 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 818 | */ | |
| 819 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 820 | for( final var pane : mTabPanes ) { | |
| 821 | for( final var tab : pane.getTabs() ) { | |
| 822 | final var node = tab.getContent(); | |
| 823 | ||
| 824 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 825 | return pane; | |
| 826 | } | |
| 827 | } | |
| 828 | } | |
| 829 | ||
| 830 | final var pane = createTabPane(); | |
| 831 | mTabPanes.add( pane ); | |
| 832 | return pane; | |
| 833 | } | |
| 834 | ||
| 835 | /** | |
| 836 | * Creates an initialized {@link TabPane} instance. | |
| 837 | * | |
| 838 | * @return A new {@link TabPane} with all listeners configured. | |
| 839 | */ | |
| 840 | private TabPane createTabPane() { | |
| 841 | final var tabPane = new DetachableTabPane(); | |
| 842 | ||
| 843 | initStageOwnerFactory( tabPane ); | |
| 844 | initTabListener( tabPane ); | |
| 845 | ||
| 846 | return tabPane; | |
| 847 | } | |
| 848 | ||
| 849 | /** | |
| 850 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 851 | * the stage owner factory must be given its parent window, which will | |
| 852 | * own the child window. The parent window is the {@link MainPane}'s | |
| 853 | * {@link Scene}'s {@link Window} instance. | |
| 854 | * | |
| 855 | * <p> | |
| 856 | * This will derives the new title from the main window title, incrementing | |
| 857 | * the window count to help uniquely identify the child windows. | |
| 858 | * </p> | |
| 859 | * | |
| 860 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 861 | */ | |
| 862 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 863 | tabPane.setStageOwnerFactory( ( stage ) -> { | |
| 864 | final var title = get( | |
| 865 | "Detach.tab.title", | |
| 866 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 867 | ); | |
| 868 | stage.setTitle( title ); | |
| 869 | ||
| 870 | return getScene().getWindow(); | |
| 871 | } ); | |
| 872 | } | |
| 873 | ||
| 874 | /** | |
| 875 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 876 | * it is added to the given {@link DetachableTabPane} instance. | |
| 877 | * <p> | |
| 878 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 879 | * is initialized to perform synchronized scrolling between the editor and | |
| 880 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 881 | * tabs is given focus. | |
| 882 | * </p> | |
| 883 | * <p> | |
| 884 | * Note that multiple tabs can be added simultaneously. | |
| 885 | * </p> | |
| 886 | * | |
| 887 | * @param tabPane A new {@link TabPane} to configure. | |
| 888 | */ | |
| 889 | private void initTabListener( final TabPane tabPane ) { | |
| 890 | tabPane.getTabs().addListener( | |
| 891 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 892 | while( listener.next() ) { | |
| 893 | if( listener.wasAdded() ) { | |
| 894 | final var tabs = listener.getAddedSubList(); | |
| 895 | ||
| 896 | tabs.forEach( tab -> { | |
| 897 | final var node = tab.getContent(); | |
| 898 | ||
| 899 | if( node instanceof TextEditor ) { | |
| 900 | initScrollEventListener( tab ); | |
| 901 | } | |
| 902 | } ); | |
| 903 | ||
| 904 | // Select and give focus to the last tab opened. | |
| 905 | final var index = tabs.size() - 1; | |
| 906 | if( index >= 0 ) { | |
| 907 | final var tab = tabs.get( index ); | |
| 908 | tabPane.getSelectionModel().select( tab ); | |
| 909 | tab.getContent().requestFocus(); | |
| 910 | } | |
| 911 | } | |
| 912 | } | |
| 913 | } | |
| 914 | ); | |
| 915 | } | |
| 916 | ||
| 917 | /** | |
| 918 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 919 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 920 | * | |
| 921 | * @param tab The container for an instance of {@link TextEditor}. | |
| 922 | */ | |
| 923 | private void initScrollEventListener( final Tab tab ) { | |
| 924 | final var editor = (TextEditor) tab.getContent(); | |
| 925 | final var scrollPane = editor.getScrollPane(); | |
| 926 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 927 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 928 | ||
| 929 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 930 | } | |
| 931 | ||
| 932 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 933 | final var items = getItems(); | |
| 934 | ||
| 935 | if( !items.contains( tabPane ) ) { | |
| 936 | items.add( index, tabPane ); | |
| 937 | } | |
| 938 | } | |
| 939 | ||
| 940 | private void addTabPane( final TabPane tabPane ) { | |
| 941 | addTabPane( getItems().size(), tabPane ); | |
| 942 | } | |
| 943 | ||
| 944 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() { | |
| 945 | return builder() | |
| 946 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 947 | .with( Mutator::setWorkspace, mWorkspace ) | |
| 948 | .with( Mutator::setCaret, () -> getTextEditor().getCaret() ); | |
| 949 | } | |
| 950 | ||
| 951 | public ProcessorContext createProcessorContext() { | |
| 952 | return createProcessorContext( null, NONE ); | |
| 953 | } | |
| 954 | ||
| 955 | /** | |
| 956 | * @param outputPath Used when exporting to a PDF file (binary). | |
| 957 | * @param format Used when processors export to a new text format. | |
| 958 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 959 | * {@link Processor}. | |
| 960 | */ | |
| 961 | public ProcessorContext createProcessorContext( | |
| 962 | final Path outputPath, final ExportFormat format ) { | |
| 963 | final var textEditor = getTextEditor(); | |
| 964 | final var inputPath = textEditor.getPath(); | |
| 965 | ||
| 966 | return createProcessorContextBuilder() | |
| 967 | .with( Mutator::setInputPath, inputPath ) | |
| 968 | .with( Mutator::setOutputPath, outputPath ) | |
| 969 | .with( Mutator::setExportFormat, format ) | |
| 970 | .build(); | |
| 971 | } | |
| 972 | ||
| 973 | /** | |
| 974 | * @param inputPath Used by {@link ProcessorFactory} to determine | |
| 975 | * {@link Processor} type to create based on file type. | |
| 976 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 977 | * {@link Processor}. | |
| 978 | */ | |
| 979 | private ProcessorContext createProcessorContext( final Path inputPath ) { | |
| 980 | return createProcessorContextBuilder() | |
| 981 | .with( Mutator::setInputPath, inputPath ) | |
| 982 | .with( Mutator::setExportFormat, NONE ) | |
| 983 | .build(); | |
| 984 | } | |
| 985 | ||
| 986 | private TextResource createTextResource( final File file ) { | |
| 987 | // TODO: Create PlainTextEditor that's returned by default. | |
| 988 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 989 | ? createDefinitionEditor( file ) | |
| 990 | : createMarkdownEditor( file ); | |
| 991 | } | |
| 992 | ||
| 993 | /** | |
| 994 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 995 | * caret change events and text change events. Text change events must | |
| 996 | * take priority over caret change events because it's possible to change | |
| 997 | * the text without moving the caret (e.g., delete selected text). | |
| 998 | * | |
| 999 | * @param inputFile The file containing contents for the text editor. | |
| 1000 | * @return A non-null text editor. | |
| 1001 | */ | |
| 1002 | private TextResource createMarkdownEditor( final File inputFile ) { | |
| 1003 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1004 | ||
| 1005 | mProcessors.computeIfAbsent( | |
| 1006 | editor, p -> createProcessors( | |
| 1007 | createProcessorContext( inputFile.toPath() ), | |
| 1008 | createHtmlPreviewProcessor() | |
| 1009 | ) | |
| 1010 | ); | |
| 1011 | ||
| 1012 | // Listener for editor modifications or caret position changes. | |
| 1013 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 1014 | if( n ) { | |
| 1015 | // Reset the status bar after changing the text. | |
| 1016 | clue(); | |
| 1017 | ||
| 1018 | // Processing the text may update the status bar. | |
| 1019 | process( getTextEditor() ); | |
| 1020 | } | |
| 1021 | } ); | |
| 1022 | ||
| 1023 | editor.addEventListener( | |
| 1024 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1025 | ); | |
| 1026 | ||
| 1027 | // Set the active editor, which refreshes the preview panel. | |
| 1028 | mTextEditor.set( editor ); | |
| 1029 | ||
| 1030 | return editor; | |
| 1031 | } | |
| 1032 | ||
| 1033 | /** | |
| 1034 | * Creates a {@link Processor} capable of rendering an HTML document onto | |
| 1035 | * a GUI widget. | |
| 1036 | * | |
| 1037 | * @return The {@link Processor} for rendering an HTML document. | |
| 1038 | */ | |
| 1039 | private Processor<String> createHtmlPreviewProcessor() { | |
| 1040 | return new HtmlPreviewProcessor( getPreview() ); | |
| 1041 | } | |
| 1042 | ||
| 1043 | /** | |
| 1044 | * See {@link #autoinsert()}. | |
| 1045 | */ | |
| 1046 | private void autoinsert( final KeyEvent ignored ) { | |
| 1047 | autoinsert(); | |
| 1048 | } | |
| 1049 | ||
| 1050 | /** | |
| 1051 | * Finds a node that matches the word at the caret, then inserts the | |
| 1052 | * corresponding definition. The definition token delimiters depend on | |
| 1053 | * the type of file being edited. | |
| 1054 | */ | |
| 1055 | public void autoinsert() { | |
| 1056 | final var editor = getTextEditor(); | |
| 1057 | final var mediaType = editor.getMediaType(); | |
| 1058 | final var injector = createInjector( mediaType ); | |
| 1059 | final var definitions = getTextDefinition(); | |
| 1060 | ||
| 1061 | VariableNameInjector.autoinsert( editor, definitions, injector ); | |
| 1062 | } | |
| 1063 | ||
| 1064 | private UnaryOperator<String> createInjector( final MediaType mediaType ) { | |
| 1065 | final String began; | |
| 1066 | final String ended; | |
| 1067 | final UnaryOperator<String> operator; | |
| 1068 | ||
| 1069 | switch( mediaType ) { | |
| 1070 | case TEXT_MARKDOWN -> { | |
| 1071 | began = getString( KEY_DEF_DELIM_BEGAN ); | |
| 1072 | ended = getString( KEY_DEF_DELIM_ENDED ); | |
| 1073 | operator = s -> s; | |
| 1074 | } | |
| 1075 | case TEXT_R_MARKDOWN -> { | |
| 1076 | began = InlineRProcessor.PREFIX + getString( KEY_R_DELIM_BEGAN ); | |
| 1077 | ended = getString( KEY_R_DELIM_ENDED ) + InlineRProcessor.SUFFIX; | |
| 1078 | operator = new RKeyOperator(); | |
| 1079 | } | |
| 1080 | case TEXT_PROPERTIES -> { | |
| 1081 | began = PropertyKeyOperator.BEGAN; | |
| 1082 | ended = PropertyKeyOperator.ENDED; | |
| 1083 | operator = s -> s; | |
| 1084 | } | |
| 1085 | default -> { | |
| 1086 | began = ""; | |
| 1087 | ended = ""; | |
| 1088 | operator = s -> s; | |
| 1089 | } | |
| 1090 | } | |
| 1091 | ||
| 1092 | return s -> began + operator.apply( s ) + ended; | |
| 1093 | } | |
| 1094 | ||
| 1095 | private String getString( final Key key ) { | |
| 1096 | assert key != null; | |
| 1097 | return getWorkspace().getString( key ); | |
| 1098 | } | |
| 1099 | ||
| 1100 | private TextDefinition createDefinitionEditor() { | |
| 1101 | return createDefinitionEditor( DEFINITION_DEFAULT ); | |
| 1102 | } | |
| 1103 | ||
| 1104 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 1105 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 1106 | ||
| 1107 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 1108 | ||
| 1109 | return editor; | |
| 1110 | } | |
| 1111 | ||
| 1112 | private TreeTransformer createTreeTransformer() { | |
| 1113 | return new YamlTreeTransformer(); | |
| 1114 | } | |
| 1115 | ||
| 1116 | private Tooltip createTooltip( final File file ) { | |
| 1117 | final var path = file.toPath(); | |
| 1118 | final var tooltip = new Tooltip( path.toString() ); | |
| 1119 | ||
| 1120 | tooltip.setShowDelay( millis( 200 ) ); | |
| 1121 | ||
| 1122 | return tooltip; | |
| 1123 | } | |
| 1124 | ||
| 1125 | public HtmlPreview getPreview() { | |
| 1126 | return mPreview; | |
| 1127 | } | |
| 1128 | ||
| 1129 | /** | |
| 1130 | * Returns the active text editor. | |
| 1131 | * | |
| 1132 | * @return The text editor that currently has focus. | |
| 1133 | */ | |
| 1134 | public TextEditor getTextEditor() { | |
| 1135 | return mTextEditor.get(); | |
| 1136 | } | |
| 1137 | ||
| 1138 | /** | |
| 1139 | * Returns the active text editor property. | |
| 1140 | * | |
| 1141 | * @return The property container for the active text editor. | |
| 1142 | */ | |
| 1143 | public ReadOnlyObjectProperty<TextEditor> textEditorProperty() { | |
| 1144 | return mTextEditor; | |
| 1145 | } | |
| 1146 | ||
| 1147 | /** | |
| 1148 | * Returns the active text definition editor. | |
| 1149 | * | |
| 1150 | * @return The property container for the active definition editor. | |
| 1151 | */ | |
| 1152 | public TextDefinition getTextDefinition() { | |
| 1153 | return mDefinitionEditor.get(); | |
| 1154 | } | |
| 1155 | ||
| 1156 | /** | |
| 1157 | * Returns the active variable definitions, without any interpolation. | |
| 1158 | * Interpolation is a responsibility of {@link Processor} instances. | |
| 1159 | * | |
| 1160 | * @return The key-value pairs, not interpolated. | |
| 1161 | */ | |
| 1162 | private Map<String, String> getDefinitions() { | |
| 1163 | return getTextDefinition().getDefinitions(); | |
| 1164 | } | |
| 1165 | ||
| 1166 | public Window getWindow() { | |
| 1167 | return getScene().getWindow(); | |
| 1168 | } | |
| 1169 | ||
| 1170 | public Workspace getWorkspace() { | |
| 1171 | return mWorkspace; | |
| 1172 | } | |
| 1173 | ||
| 1174 | /** | |
| 1175 | * Returns the set of file names opened in the application. The names must | |
| 1176 | * be converted to {@link File} objects. | |
| 1177 | * | |
| 1178 | * @return A {@link Set} of file names. | |
| 1179 | */ | |
| 1180 | private <E> SetProperty<E> getRecentFiles() { | |
| 1181 | return getWorkspace().setsProperty( KEY_UI_RECENT_OPEN_PATH ); | |
| 1110 | 1182 | } |
| 1111 | 1183 | } |
| 22 | 22 | import static com.keenwrite.events.StatusEvent.clue; |
| 23 | 23 | import static com.keenwrite.preferences.SkinProperty.toFilename; |
| 24 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_SKIN_CUSTOM; | |
| 25 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_SKIN_SELECTION; | |
| 24 | import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_CUSTOM; | |
| 25 | import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_SELECTION; | |
| 26 | 26 | import static com.keenwrite.ui.actions.ApplicationBars.*; |
| 27 | 27 | import static javafx.application.Platform.runLater; |
| ... | ||
| 34 | 34 | */ |
| 35 | 35 | public final class MainScene { |
| 36 | ||
| 36 | 37 | private final Scene mScene; |
| 37 | 38 | private final MenuBar mMenuBar; |
| ... | ||
| 179 | 180 | */ |
| 180 | 181 | private CaretListener createCaretListener( final MainPane mainPane ) { |
| 181 | return new CaretListener( mainPane.activeTextEditorProperty() ); | |
| 182 | return new CaretListener( mainPane.textEditorProperty() ); | |
| 182 | 183 | } |
| 183 | 184 | |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Key; |
| 5 | import com.keenwrite.sigils.SigilOperator; | |
| 6 | import com.keenwrite.util.InterpolatingMap; | |
| 5 | import com.keenwrite.sigils.PropertyKeyOperator; | |
| 6 | import com.keenwrite.sigils.SigilKeyOperator; | |
| 7 | import com.keenwrite.collections.InterpolatingMap; | |
| 7 | 8 | |
| 8 | 9 | import java.text.MessageFormat; |
| ... | ||
| 18 | 19 | public final class Messages { |
| 19 | 20 | |
| 20 | private static final SigilOperator OPERATOR = createBundleSigilOperator(); | |
| 21 | private static final InterpolatingMap MAP = new InterpolatingMap(); | |
| 21 | private static final SigilKeyOperator OPERATOR = new PropertyKeyOperator(); | |
| 22 | private static final InterpolatingMap MAP = new InterpolatingMap( OPERATOR ); | |
| 22 | 23 | |
| 23 | 24 | static { |
| 24 | 25 | // Obtains the application resource bundle using the default locale. The |
| 25 | 26 | // locale cannot be changed using the application, making interpolation of |
| 26 | 27 | // values viable as a one-time operation. |
| 27 | final var BUNDLE = getBundle( APP_BUNDLE_NAME ); | |
| 28 | BUNDLE.keySet().forEach( key -> MAP.put( key, BUNDLE.getString( key ) ) ); | |
| 29 | MAP.interpolate( OPERATOR ); | |
| 30 | } | |
| 28 | final var bundle = getBundle( APP_BUNDLE_NAME ); | |
| 31 | 29 | |
| 32 | private Messages() { | |
| 30 | bundle.keySet().forEach( key -> MAP.put( key, bundle.getString( key ) ) ); | |
| 31 | MAP.interpolate(); | |
| 33 | 32 | } |
| 34 | 33 | |
| ... | ||
| 41 | 40 | */ |
| 42 | 41 | public static String get( final String key ) { |
| 43 | final var v = MAP.get( OPERATOR.entoken( key ) ); | |
| 42 | final var v = MAP.get( key ); | |
| 43 | ||
| 44 | 44 | return v == null ? key : v; |
| 45 | 45 | } |
| ... | ||
| 75 | 75 | */ |
| 76 | 76 | public static boolean containsKey( final String key ) { |
| 77 | return MAP.containsKey( OPERATOR.entoken( key ) ); | |
| 77 | return MAP.containsKey( key ); | |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | private static SigilOperator createBundleSigilOperator() { | |
| 81 | return new SigilOperator( "${", "}" ); | |
| 82 | } | |
| 80 | private Messages() {} | |
| 83 | 81 | } |
| 84 | 82 | |
| 49 | 49 | |
| 50 | 50 | /** |
| 51 | * Use {@link #installTrustManager()}. | |
| 52 | */ | |
| 53 | private PermissiveCertificate() { | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | 51 | * Install the all-trusting trust manager. If this fails it means that in |
| 58 | 52 | * certain situations the HTML preview may fail to render diagrams. A way |
| 59 | * to work-around the issue is to install a local server for generating | |
| 53 | * to work around the issue is to install a local server for generating | |
| 60 | 54 | * diagrams. |
| 61 | 55 | */ |
| ... | ||
| 70 | 64 | return false; |
| 71 | 65 | } |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Use {@link #installTrustManager()}. | |
| 70 | */ | |
| 71 | private PermissiveCertificate() { | |
| 72 | 72 | } |
| 73 | 73 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.editors.TextDefinition; | |
| 5 | import com.keenwrite.editors.TextEditor; | |
| 6 | import com.keenwrite.editors.definition.DefinitionTreeItem; | |
| 7 | ||
| 8 | import java.util.function.UnaryOperator; | |
| 9 | ||
| 10 | import static com.keenwrite.constants.Constants.*; | |
| 11 | import static com.keenwrite.events.StatusEvent.clue; | |
| 12 | ||
| 13 | /** | |
| 14 | * Provides the logic for injecting variable names within the editor. | |
| 15 | */ | |
| 16 | public final class VariableNameInjector { | |
| 17 | ||
| 18 | /** | |
| 19 | * Find a node that matches the current word and substitute the definition | |
| 20 | * reference. | |
| 21 | */ | |
| 22 | public static void autoinsert( | |
| 23 | final TextEditor editor, | |
| 24 | final TextDefinition definitions, | |
| 25 | final UnaryOperator<String> operator ) { | |
| 26 | assert editor != null; | |
| 27 | assert definitions != null; | |
| 28 | assert operator != null; | |
| 29 | ||
| 30 | try { | |
| 31 | if( definitions.isEmpty() ) { | |
| 32 | clue( STATUS_DEFINITION_EMPTY ); | |
| 33 | } | |
| 34 | else { | |
| 35 | final var indexes = editor.getCaretWord(); | |
| 36 | final var word = editor.getText( indexes ); | |
| 37 | ||
| 38 | if( word.isBlank() ) { | |
| 39 | clue( STATUS_DEFINITION_BLANK ); | |
| 40 | } | |
| 41 | else { | |
| 42 | final var leaf = findLeaf( definitions, word ); | |
| 43 | ||
| 44 | if( leaf == null ) { | |
| 45 | clue( STATUS_DEFINITION_MISSING, word ); | |
| 46 | } | |
| 47 | else { | |
| 48 | editor.replaceText( indexes, operator.apply( leaf.toPath() ) ); | |
| 49 | definitions.expand( leaf ); | |
| 50 | } | |
| 51 | } | |
| 52 | } | |
| 53 | } catch( final Exception ex ) { | |
| 54 | clue( STATUS_DEFINITION_BLANK, ex ); | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Looks for the given word, matching first by exact, next by a starts-with | |
| 60 | * condition with diacritics replaced, then by containment. | |
| 61 | * | |
| 62 | * @param word Match the word by: exact, beginning, containment, or other. | |
| 63 | */ | |
| 64 | @SuppressWarnings( "ConstantConditions" ) | |
| 65 | private static DefinitionTreeItem<String> findLeaf( | |
| 66 | final TextDefinition definition, final String word ) { | |
| 67 | assert definition != null; | |
| 68 | assert word != null; | |
| 69 | ||
| 70 | DefinitionTreeItem<String> leaf = null; | |
| 71 | ||
| 72 | leaf = leaf == null ? definition.findLeafExact( word ) : leaf; | |
| 73 | leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf; | |
| 74 | leaf = leaf == null ? definition.findLeafContains( word ) : leaf; | |
| 75 | leaf = leaf == null ? definition.findLeafContainsNoCase( word ) : leaf; | |
| 76 | ||
| 77 | return leaf; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Prevent instantiation. | |
| 82 | */ | |
| 83 | private VariableNameInjector() {} | |
| 84 | } | |
| 1 | 85 |
| 2 | 2 | |
| 3 | 3 | import com.keenwrite.ExportFormat; |
| 4 | import com.keenwrite.preferences.Key; | |
| 5 | import com.keenwrite.preferences.KeyConfiguration; | |
| 4 | 6 | import com.keenwrite.processors.ProcessorContext; |
| 5 | 7 | import com.keenwrite.processors.ProcessorContext.Mutator; |
| 6 | 8 | import picocli.CommandLine; |
| 7 | 9 | |
| 8 | 10 | import java.io.File; |
| 9 | 11 | import java.nio.file.Path; |
| 12 | import java.util.HashMap; | |
| 10 | 13 | import java.util.Map; |
| 11 | 14 | import java.util.Set; |
| 12 | 15 | import java.util.concurrent.Callable; |
| 13 | 16 | import java.util.function.Consumer; |
| 17 | ||
| 18 | import static com.keenwrite.preferences.AppKeys.*; | |
| 14 | 19 | |
| 20 | /** | |
| 21 | * Responsible for mapping command-line arguments to keys that are used by | |
| 22 | * the application. This class implements the {@link KeyConfiguration} as | |
| 23 | * an abstraction so that the CLI and GUI can reuse the same code, but without | |
| 24 | * the CLI needing to instantiate or initialize JavaFX. | |
| 25 | */ | |
| 15 | 26 | @CommandLine.Command( |
| 16 | 27 | name = "KeenWrite", |
| 17 | 28 | mixinStandardHelpOptions = true, |
| 18 | 29 | description = "Plain text editor for editing with variables." |
| 19 | 30 | ) |
| 20 | 31 | @SuppressWarnings( "unused" ) |
| 21 | public final class Arguments implements Callable<Integer> { | |
| 32 | public final class Arguments implements Callable<Integer>, KeyConfiguration { | |
| 22 | 33 | @CommandLine.Option( |
| 23 | 34 | names = {"-a", "--all"}, |
| 24 | 35 | description = |
| 25 | 36 | "Concatenate files in directory before processing (${DEFAULT-VALUE}).", |
| 26 | 37 | defaultValue = "false" |
| 27 | 38 | ) |
| 28 | 39 | private boolean mAll; |
| 40 | ||
| 41 | @CommandLine.Option( | |
| 42 | names = {"-k", "--keep-files"}, | |
| 43 | description = | |
| 44 | "Keep temporary build files (${DEFAULT-VALUE}).", | |
| 45 | defaultValue = "false" | |
| 46 | ) | |
| 47 | private boolean mKeepFiles; | |
| 29 | 48 | |
| 30 | 49 | @CommandLine.Option( |
| ... | ||
| 40 | 59 | description = |
| 41 | 60 | "Set the file name to read.", |
| 42 | paramLabel = "FILE", | |
| 61 | paramLabel = "PATH", | |
| 43 | 62 | defaultValue = "stdin", |
| 44 | 63 | required = true |
| 45 | 64 | ) |
| 46 | private File mFileInput; | |
| 65 | private Path mPathInput; | |
| 47 | 66 | |
| 48 | 67 | @CommandLine.Option( |
| ... | ||
| 68 | 87 | description = |
| 69 | 88 | "Set the file name to write.", |
| 70 | paramLabel = "FILE", | |
| 89 | paramLabel = "PATH", | |
| 71 | 90 | defaultValue = "stdout", |
| 72 | 91 | required = true |
| 73 | 92 | ) |
| 74 | private File mFileOutput; | |
| 93 | private File mPathOutput; | |
| 75 | 94 | |
| 76 | 95 | @CommandLine.Option( |
| 77 | 96 | names = {"-p", "--images-path"}, |
| 78 | 97 | description = |
| 79 | 98 | "Absolute path to images directory", |
| 80 | 99 | paramLabel = "PATH" |
| 81 | 100 | ) |
| 82 | private Path mImages; | |
| 101 | private Path mPathImages; | |
| 83 | 102 | |
| 84 | 103 | @CommandLine.Option( |
| ... | ||
| 104 | 123 | paramLabel = "PATH" |
| 105 | 124 | ) |
| 106 | private String mThemeName; | |
| 125 | private Path mThemeName; | |
| 107 | 126 | |
| 108 | 127 | @CommandLine.Option( |
| 109 | 128 | names = {"-x", "--image-extensions"}, |
| 110 | 129 | description = |
| 111 | 130 | "Space-separated image file name extensions (${DEFAULT-VALUE}).", |
| 112 | 131 | paramLabel = "String", |
| 113 | 132 | defaultValue = "svg pdf png jpg tiff" |
| 114 | 133 | ) |
| 115 | private Set<String> mExtensions; | |
| 134 | private Set<String> mImageExtensions; | |
| 116 | 135 | |
| 117 | 136 | @CommandLine.Option( |
| 118 | 137 | names = {"-v", "--variables"}, |
| 119 | 138 | description = |
| 120 | 139 | "Set the file name containing variable definitions (${DEFAULT-VALUE}).", |
| 121 | 140 | paramLabel = "FILE", |
| 122 | 141 | defaultValue = "variables.yaml" |
| 123 | 142 | ) |
| 124 | private String mFileVariables; | |
| 143 | private Path mPathVariables; | |
| 125 | 144 | |
| 126 | 145 | private final Consumer<Arguments> mLauncher; |
| 146 | ||
| 147 | private final Map<Key, Object> mValues = new HashMap<>(); | |
| 127 | 148 | |
| 128 | 149 | public Arguments( final Consumer<Arguments> launcher ) { |
| 129 | 150 | mLauncher = launcher; |
| 130 | 151 | } |
| 131 | 152 | |
| 132 | 153 | public ProcessorContext createProcessorContext() { |
| 154 | mValues.put( KEY_UI_RECENT_DOCUMENT, mPathInput ); | |
| 155 | mValues.put( KEY_UI_RECENT_DEFINITION, mPathVariables ); | |
| 156 | mValues.put( KEY_UI_RECENT_EXPORT, mPathOutput ); | |
| 157 | mValues.put( KEY_IMAGES_DIR, mPathImages ); | |
| 158 | mValues.put( KEY_TYPESET_CONTEXT_THEMES_PATH, mThemeName.getParent() ); | |
| 159 | mValues.put( KEY_TYPESET_CONTEXT_THEME_SELECTION, mThemeName.getFileName() ); | |
| 160 | mValues.put( KEY_TYPESET_CONTEXT_CLEAN, !mKeepFiles ); | |
| 161 | ||
| 133 | 162 | final var format = ExportFormat.valueFrom( mFormatType, mFormatSubtype ); |
| 163 | ||
| 134 | 164 | return ProcessorContext |
| 135 | 165 | .builder() |
| 136 | .with( Mutator::setInputPath, mFileInput ) | |
| 137 | .with( Mutator::setOutputPath, mFileOutput ) | |
| 166 | .with( Mutator::setInputPath, mPathInput ) | |
| 167 | .with( Mutator::setOutputPath, mPathOutput ) | |
| 138 | 168 | .with( Mutator::setExportFormat, format ) |
| 139 | 169 | .build(); |
| ... | ||
| 158 | 188 | public Integer call() throws Exception { |
| 159 | 189 | mLauncher.accept( this ); |
| 190 | return 0; | |
| 191 | } | |
| 192 | ||
| 193 | @Override | |
| 194 | public String getString( final Key key ) { | |
| 195 | return null; | |
| 196 | } | |
| 197 | ||
| 198 | @Override | |
| 199 | public boolean getBoolean( final Key key ) { | |
| 200 | return false; | |
| 201 | } | |
| 202 | ||
| 203 | @Override | |
| 204 | public int getInteger( final Key key ) { | |
| 205 | return 0; | |
| 206 | } | |
| 207 | ||
| 208 | @Override | |
| 209 | public double getDouble( final Key key ) { | |
| 160 | 210 | return 0; |
| 211 | } | |
| 212 | ||
| 213 | @Override | |
| 214 | public File getFile( final Key key ) { | |
| 215 | return null; | |
| 161 | 216 | } |
| 162 | 217 | } |
| 9 | 9 | */ |
| 10 | 10 | public class ColourScheme { |
| 11 | ||
| 12 | /** | |
| 13 | * Creates a new color scheme for use with command-line parsing. | |
| 14 | * | |
| 15 | * @return The new color scheme to apply to the parsesr. | |
| 16 | */ | |
| 11 | 17 | public static ColorScheme create() { |
| 12 | 18 | return new Builder() |
| 2 | 2 | |
| 3 | 3 | import com.keenwrite.AppCommands; |
| 4 | import com.keenwrite.events.StatusEvent; | |
| 5 | import org.greenrobot.eventbus.Subscribe; | |
| 6 | ||
| 7 | import static com.keenwrite.events.Bus.register; | |
| 4 | 8 | |
| 5 | 9 | /** |
| 6 | 10 | * Responsible for running the application in headless mode. |
| 7 | 11 | */ |
| 8 | 12 | public class HeadlessApp { |
| 13 | ||
| 14 | /** | |
| 15 | * Contains directives that control text file processing. | |
| 16 | */ | |
| 17 | private final Arguments mArgs; | |
| 18 | ||
| 19 | /** | |
| 20 | * Creates a new command-line version of the application. | |
| 21 | * | |
| 22 | * @param args The post-processed command-line arguments. | |
| 23 | */ | |
| 24 | public HeadlessApp( final Arguments args ) { | |
| 25 | assert args != null; | |
| 26 | ||
| 27 | mArgs = args; | |
| 28 | ||
| 29 | register( this ); | |
| 30 | AppCommands.run( mArgs ); | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * When a status message is shown, write it to the console, if not in | |
| 35 | * quiet mode. | |
| 36 | * | |
| 37 | * @param event The event published when the status changes. | |
| 38 | */ | |
| 39 | @Subscribe | |
| 40 | public void handle( final StatusEvent event ) { | |
| 41 | if( !mArgs.quiet() ) { | |
| 42 | System.out.println( event ); | |
| 43 | } | |
| 44 | } | |
| 9 | 45 | |
| 10 | 46 | /** |
| 11 | 47 | * Entry point for running the application in headless mode. |
| 12 | 48 | * |
| 13 | 49 | * @param args The parsed command-line arguments. |
| 14 | 50 | */ |
| 15 | 51 | public static void main( final Arguments args ) { |
| 16 | AppCommands.run( args ); | |
| 52 | new HeadlessApp( args ); | |
| 17 | 53 | } |
| 18 | 54 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.collections; | |
| 3 | ||
| 4 | import java.util.LinkedHashMap; | |
| 5 | import java.util.Map; | |
| 6 | ||
| 7 | /** | |
| 8 | * A map that removes the oldest entry once its capacity (cache size) has | |
| 9 | * been reached. | |
| 10 | * | |
| 11 | * @param <K> The type of key mapped to a value. | |
| 12 | * @param <V> The type of value mapped to a key. | |
| 13 | */ | |
| 14 | public final class BoundedCache<K, V> extends LinkedHashMap<K, V> { | |
| 15 | private final int mCacheSize; | |
| 16 | ||
| 17 | /** | |
| 18 | * Constructs a new instance having a finite size. | |
| 19 | * | |
| 20 | * @param cacheSize The maximum number of entries. | |
| 21 | */ | |
| 22 | public BoundedCache( final int cacheSize ) { | |
| 23 | mCacheSize = cacheSize; | |
| 24 | } | |
| 25 | ||
| 26 | @Override | |
| 27 | protected boolean removeEldestEntry( final Map.Entry<K, V> eldest ) { | |
| 28 | return size() > mCacheSize; | |
| 29 | } | |
| 30 | } | |
| 1 | 31 |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.collections; | |
| 3 | ||
| 4 | import java.util.*; | |
| 5 | ||
| 6 | import static java.lang.Math.min; | |
| 7 | ||
| 8 | /** | |
| 9 | * Responsible for maintaining a circular queue where newly added items will | |
| 10 | * overwrite existing items. | |
| 11 | * | |
| 12 | * <strong>Warning:</strong> This class is not thread-safe. | |
| 13 | * | |
| 14 | * @param <E> The type of elements to store in this collection. | |
| 15 | */ | |
| 16 | @SuppressWarnings( "unchecked" ) | |
| 17 | public class CircularQueue<E> | |
| 18 | extends AbstractCollection<E> implements Queue<E> { | |
| 19 | ||
| 20 | /** | |
| 21 | * Simplifies the code by reusing an existing list implementation. | |
| 22 | * Initialized with {@code null} values at construction time. | |
| 23 | */ | |
| 24 | private final Object[] mElements; | |
| 25 | ||
| 26 | /** | |
| 27 | * Maximum number of elements allowed in the collection before old elements | |
| 28 | * are overwritten. Set at construction time. | |
| 29 | */ | |
| 30 | private final int mCapacity; | |
| 31 | ||
| 32 | /** | |
| 33 | * Insertion position when a new element is added. Starts at zero. | |
| 34 | */ | |
| 35 | private int mProducer; | |
| 36 | ||
| 37 | /** | |
| 38 | * Retrieval position when the oldest element is removed. Starts at zero. | |
| 39 | */ | |
| 40 | private int mConsumer; | |
| 41 | ||
| 42 | /** | |
| 43 | * The number of elements in the collection. This cannot delegate to the | |
| 44 | * {@link #mElements} list. Starts at zero. | |
| 45 | */ | |
| 46 | private int mSize; | |
| 47 | ||
| 48 | /** | |
| 49 | * Creates a new circular queue that has a limited number of elements that | |
| 50 | * may be added before newly added elements will overwrite the oldest | |
| 51 | * elements that were added previously. | |
| 52 | * <p> | |
| 53 | * <strong>Warning:</strong> Client classes must take care not to exceed | |
| 54 | * memory limits imposed by the Java Virtual Machine. | |
| 55 | * | |
| 56 | * @param capacity Maximum number elements allowed in the list, must be | |
| 57 | * greater than one. | |
| 58 | */ | |
| 59 | public CircularQueue( final int capacity ) { | |
| 60 | assert capacity > 1; | |
| 61 | ||
| 62 | mCapacity = capacity; | |
| 63 | mElements = new Object[ capacity ]; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Adds an element to the end of the collection. This overwrites the oldest | |
| 68 | * element in the collection when the queue is full. The number of elements, | |
| 69 | * reflected by the return value of {@link #size()} will not exceed the | |
| 70 | * capacity. | |
| 71 | * | |
| 72 | * @param element The item to insert into the collection, must not be | |
| 73 | * {@code null}. | |
| 74 | * @return {@code true} Non-{@code null} items are always added. | |
| 75 | * @throws NullPointerException if the given element is {@code null}. | |
| 76 | * The iterator requires a consecutive | |
| 77 | * non-{@code null} range (no gaps). | |
| 78 | */ | |
| 79 | @Override | |
| 80 | public boolean add( final E element ) { | |
| 81 | if( element == null ) { | |
| 82 | throw new NullPointerException(); | |
| 83 | } | |
| 84 | ||
| 85 | mElements[ mProducer++ ] = element; | |
| 86 | mProducer %= mCapacity; | |
| 87 | mSize = min( mSize + 1, mCapacity ); | |
| 88 | ||
| 89 | return true; | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Delegates to {@link #add(E)}. | |
| 94 | */ | |
| 95 | @Override | |
| 96 | public boolean offer( final E element ) { | |
| 97 | return add( element ); | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Removes the oldest element that was added to the collection. The number | |
| 102 | * of elements reflected by the return value of {@link #size()} will not | |
| 103 | * drop below zero. | |
| 104 | * | |
| 105 | * @return The oldest element. | |
| 106 | * @throws NoSuchElementException The collection is empty. | |
| 107 | */ | |
| 108 | @Override | |
| 109 | public E remove() { | |
| 110 | if( isEmpty() ) { | |
| 111 | throw new NoSuchElementException(); | |
| 112 | } | |
| 113 | ||
| 114 | final E element = (E) mElements[ mConsumer ]; | |
| 115 | ||
| 116 | mElements[ mConsumer++ ] = null; | |
| 117 | mConsumer %= mCapacity; | |
| 118 | mSize--; | |
| 119 | ||
| 120 | return element; | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Delegates to {@link #remove()}, but does not throw an exception. | |
| 125 | * | |
| 126 | * @return The oldest element. | |
| 127 | */ | |
| 128 | @Override | |
| 129 | public E poll() { | |
| 130 | return isEmpty() ? null : remove(); | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Returns the oldest element that was added to the collection. | |
| 135 | * | |
| 136 | * @return The oldest element. | |
| 137 | * @throws NoSuchElementException The collection is empty. | |
| 138 | */ | |
| 139 | @Override | |
| 140 | public E element() { | |
| 141 | if( isEmpty() ) { | |
| 142 | throw new NoSuchElementException(); | |
| 143 | } | |
| 144 | ||
| 145 | return (E) mElements[ mConsumer ]; | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Delegates to {@link #element()}, but does not throw an exception. | |
| 150 | * | |
| 151 | * @return The oldest element. | |
| 152 | */ | |
| 153 | @Override | |
| 154 | public E peek() { | |
| 155 | return isEmpty() ? null : element(); | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Answers how many elements are currently in the collection. | |
| 160 | * | |
| 161 | * @return The number of elements that have been added to but not removed | |
| 162 | * from the collection. | |
| 163 | */ | |
| 164 | @Override | |
| 165 | public int size() { | |
| 166 | return mSize; | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Returns a facility to visit each of the elements in the | |
| 171 | * {@link CircularQueue}. This will start iterating at the oldest element | |
| 172 | * and stop when there are no more elements. | |
| 173 | * <p> | |
| 174 | * The iterator is not thread-safe; concurrent modifications to the number | |
| 175 | * of elements in the {@link CircularQueue} will result in undefined | |
| 176 | * behaviour. | |
| 177 | * | |
| 178 | * @return A new {@link Iterator} instance capable of visiting each element. | |
| 179 | */ | |
| 180 | @Override | |
| 181 | public Iterator<E> iterator() { | |
| 182 | return new Iterator<>() { | |
| 183 | private int mIndex = mConsumer; | |
| 184 | private boolean mFirst = true; | |
| 185 | ||
| 186 | @Override | |
| 187 | public boolean hasNext() { | |
| 188 | return (mFirst || mIndex != mConsumer) && mElements[ mIndex ] != null; | |
| 189 | } | |
| 190 | ||
| 191 | @Override | |
| 192 | public E next() { | |
| 193 | final var element = mElements[ mIndex++ ]; | |
| 194 | mIndex %= mCapacity; | |
| 195 | mFirst = false; | |
| 196 | ||
| 197 | return (E) element; | |
| 198 | } | |
| 199 | }; | |
| 200 | } | |
| 201 | ||
| 202 | @Override | |
| 203 | public String toString() { | |
| 204 | return Arrays.toString( mElements ); | |
| 205 | } | |
| 206 | } | |
| 1 | 207 |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.collections; | |
| 3 | ||
| 4 | import com.keenwrite.sigils.SigilKeyOperator; | |
| 5 | ||
| 6 | import java.util.HashMap; | |
| 7 | import java.util.Map; | |
| 8 | import java.util.concurrent.ConcurrentHashMap; | |
| 9 | ||
| 10 | /** | |
| 11 | * Responsible for interpolating key-value pairs in a map. That is, this will | |
| 12 | * iterate over all key-value pairs and replace keys wrapped in sigils | |
| 13 | * with corresponding definition value from the same map. | |
| 14 | */ | |
| 15 | public class InterpolatingMap extends ConcurrentHashMap<String, String> { | |
| 16 | private static final int GROUP_DELIMITED = 1; | |
| 17 | ||
| 18 | /** | |
| 19 | * Used to override the default initial capacity in {@link HashMap}. | |
| 20 | */ | |
| 21 | private static final int INITIAL_CAPACITY = 1 << 8; | |
| 22 | ||
| 23 | private final SigilKeyOperator mOperator; | |
| 24 | ||
| 25 | /** | |
| 26 | * @param operator Contains the opening and closing sigils that mark | |
| 27 | * where variable names begin and end. | |
| 28 | */ | |
| 29 | public InterpolatingMap( final SigilKeyOperator operator ) { | |
| 30 | super( INITIAL_CAPACITY ); | |
| 31 | ||
| 32 | assert operator != null; | |
| 33 | mOperator = operator; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * @param operator Contains the opening and closing sigils that mark | |
| 38 | * where variable names begin and end. | |
| 39 | * @param m The initial {@link Map} to copy into this instance. | |
| 40 | */ | |
| 41 | public InterpolatingMap( | |
| 42 | final SigilKeyOperator operator, final Map<String, String> m ) { | |
| 43 | this( operator ); | |
| 44 | putAll( m ); | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Interpolates all values in the map that reference other values by way | |
| 49 | * of key names. Performs a non-greedy match of key names delimited by | |
| 50 | * definition tokens. This operation modifies the map directly. | |
| 51 | * | |
| 52 | * @return {@code this} | |
| 53 | */ | |
| 54 | public Map<String, String> interpolate() { | |
| 55 | for( final var k : keySet() ) { | |
| 56 | replace( k, interpolate( get( k ) ) ); | |
| 57 | } | |
| 58 | ||
| 59 | return this; | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Given a value with zero or more key references, this will resolve all | |
| 64 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 65 | * contain the key name, including the original sigils. | |
| 66 | * | |
| 67 | * @param value Value containing zero or more key references. | |
| 68 | * @return The given value with all embedded key references interpolated. | |
| 69 | */ | |
| 70 | public String interpolate( String value ) { | |
| 71 | assert value != null; | |
| 72 | ||
| 73 | final var matcher = mOperator.match( value ); | |
| 74 | ||
| 75 | while( matcher.find() ) { | |
| 76 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 77 | final var mapValue = get( keyName ); | |
| 78 | ||
| 79 | if( mapValue != null ) { | |
| 80 | final var keyValue = interpolate( mapValue ); | |
| 81 | value = value.replace( mOperator.apply( keyName ), keyValue ); | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | return value; | |
| 86 | } | |
| 87 | } | |
| 1 | 88 |
| 62 | 62 | "file.stylesheet.preview.locale"; |
| 63 | 63 | |
| 64 | public static final String FILE_PREFERENCES = getPreferencesFilename(); | |
| 64 | public static final File FILE_PREFERENCES = getPreferencesFile(); | |
| 65 | 65 | |
| 66 | 66 | /** |
| ... | ||
| 258 | 258 | * Returns the equivalent of {@code $HOME/.filename.xml}. |
| 259 | 259 | */ |
| 260 | private static String getPreferencesFilename() { | |
| 261 | return format( | |
| 260 | private static File getPreferencesFile() { | |
| 261 | return new File( format( | |
| 262 | 262 | "%s%s.%s.xml", |
| 263 | 263 | getProperty( "user.home" ), |
| 264 | 264 | separator, |
| 265 | 265 | APP_TITLE_LOWERCASE |
| 266 | ); | |
| 266 | ) ); | |
| 267 | 267 | } |
| 268 | 268 | } |
| 16 | 16 | import javax.xml.xpath.XPathExpressionException; |
| 17 | 17 | import javax.xml.xpath.XPathFactory; |
| 18 | import java.io.IOException; | |
| 19 | import java.io.InputStream; | |
| 20 | import java.io.StringReader; | |
| 21 | import java.io.StringWriter; | |
| 18 | import java.io.*; | |
| 22 | 19 | import java.nio.file.Path; |
| 23 | 20 | import java.util.HashMap; |
| ... | ||
| 36 | 33 | private static final String LOAD_EXTERNAL_DTD = |
| 37 | 34 | "http://apache.org/xml/features/nonvalidating/load-external-dtd"; |
| 35 | private static final String INDENT_AMOUNT = | |
| 36 | "{http://xml.apache.org/xslt}indent-amount"; | |
| 38 | 37 | |
| 39 | 38 | /** |
| ... | ||
| 64 | 63 | sTransformer.setOutputProperty( OMIT_XML_DECLARATION, "yes" ); |
| 65 | 64 | sTransformer.setOutputProperty( METHOD, "xml" ); |
| 66 | sTransformer.setOutputProperty( INDENT, "no" ); | |
| 67 | 65 | sTransformer.setOutputProperty( ENCODING, UTF_8.toString() ); |
| 66 | sTransformer.setOutputProperty( INDENT, "yes" ); | |
| 67 | sTransformer.setOutputProperty( INDENT_AMOUNT, "2" ); | |
| 68 | 68 | } catch( final Exception ex ) { |
| 69 | 69 | clue( ex ); |
| 70 | 70 | } |
| 71 | 71 | } |
| 72 | ||
| 73 | /** | |
| 74 | * Use the {@code static} constants and methods, not an instance, at least | |
| 75 | * until an iterable sub-interface is written. | |
| 76 | */ | |
| 77 | private DocumentParser() {} | |
| 78 | 72 | |
| 79 | 73 | public static Document newDocument() { |
| ... | ||
| 90 | 84 | */ |
| 91 | 85 | public static Document parse( final String xml ) { |
| 86 | assert xml != null; | |
| 87 | ||
| 92 | 88 | final var input = new InputSource(); |
| 93 | 89 | |
| ... | ||
| 101 | 97 | |
| 102 | 98 | return sDocumentBuilder.newDocument(); |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Parses the given file contents into a document object model. | |
| 104 | * | |
| 105 | * @param doc The source XML document to parse. | |
| 106 | * @return The file as a document object model. | |
| 107 | * @throws IOException Could not open the document. | |
| 108 | * @throws SAXException Could not read the XML file content. | |
| 109 | */ | |
| 110 | public static Document parse( final File doc ) | |
| 111 | throws IOException, SAXException { | |
| 112 | assert doc != null; | |
| 113 | ||
| 114 | try( final var in = new FileInputStream( doc ) ) { | |
| 115 | return parse( in ); | |
| 103 | 116 | } |
| 104 | 117 | } |
| 105 | 118 | |
| 119 | /** | |
| 120 | * Parses the given file contents into a document object model. Callers | |
| 121 | * must close the stream. | |
| 122 | * | |
| 123 | * @param doc The source XML document to parse. | |
| 124 | * @return The {@link InputStream} converted to a document object model. | |
| 125 | * @throws IOException Could not open the document. | |
| 126 | * @throws SAXException Could not read the XML file content. | |
| 127 | */ | |
| 106 | 128 | public static Document parse( final InputStream doc ) |
| 107 | 129 | throws IOException, SAXException { |
| 130 | assert doc != null; | |
| 131 | ||
| 108 | 132 | return sDocumentBuilder.parse( doc ); |
| 109 | 133 | } |
| ... | ||
| 117 | 141 | * @param consumer The consumer to call for each matching document node. |
| 118 | 142 | */ |
| 119 | public static void walk( | |
| 143 | public static void visit( | |
| 120 | 144 | final Document document, |
| 121 | final String xpath, | |
| 145 | final CharSequence xpath, | |
| 122 | 146 | final Consumer<Node> consumer ) { |
| 123 | 147 | assert document != null; |
| 124 | 148 | assert consumer != null; |
| 125 | 149 | |
| 126 | 150 | try { |
| 127 | final var expr = lookupXPathExpression( xpath ); | |
| 128 | final var nodes = (NodeList) expr.evaluate( document, NODESET ); | |
| 151 | final var expr = compile( xpath ); | |
| 152 | final var nodeSet = expr.evaluate( document, NODESET ); | |
| 129 | 153 | |
| 130 | if( nodes != null ) { | |
| 154 | if( nodeSet instanceof NodeList nodes ) { | |
| 131 | 155 | for( int i = 0, len = nodes.getLength(); i < len; i++ ) { |
| 132 | 156 | consumer.accept( nodes.item( i ) ); |
| ... | ||
| 140 | 164 | public static Node createMeta( |
| 141 | 165 | final Document document, final Map.Entry<String, String> entry ) { |
| 166 | assert document != null; | |
| 167 | assert entry != null; | |
| 168 | ||
| 142 | 169 | final var node = document.createElement( "meta" ); |
| 143 | 170 | |
| 144 | 171 | node.setAttribute( "name", entry.getKey() ); |
| 145 | 172 | node.setAttribute( "content", entry.getValue() ); |
| 146 | 173 | |
| 147 | 174 | return node; |
| 148 | 175 | } |
| 149 | 176 | |
| 150 | 177 | public static String toString( final Document xhtml ) { |
| 178 | assert xhtml != null; | |
| 179 | ||
| 151 | 180 | try( final var writer = new StringWriter() ) { |
| 152 | 181 | final var domSource = new DOMSource( xhtml ); |
| ... | ||
| 164 | 193 | public static String transform( final Element root ) |
| 165 | 194 | throws IOException, TransformerException { |
| 195 | assert root != null; | |
| 196 | ||
| 166 | 197 | try( final var writer = new StringWriter() ) { |
| 167 | 198 | sTransformer.transform( |
| ... | ||
| 180 | 211 | * @throws Exception The file could not be processed. |
| 181 | 212 | */ |
| 182 | public static void sanitize( final Path path ) | |
| 183 | throws Exception { | |
| 213 | public static void sanitize( final Path path ) throws Exception { | |
| 214 | assert path != null; | |
| 215 | ||
| 184 | 216 | final var file = path.toFile(); |
| 185 | 217 | |
| 186 | 218 | sTransformer.transform( |
| 187 | 219 | new DOMSource( sDocumentBuilder.parse( file ) ), new StreamResult( file ) |
| 188 | 220 | ); |
| 189 | 221 | } |
| 190 | 222 | |
| 191 | 223 | /** |
| 192 | * Adorns the given document with {@code html}, {@code head}, and | |
| 193 | * {@code body} elements. | |
| 224 | * Converts a string into an {@link XPathExpression}, which may be used to | |
| 225 | * extract elements from a {@link Document} object model. | |
| 194 | 226 | * |
| 195 | * @param html The document to decorate. | |
| 196 | * @return A document with a typical HTML structure. | |
| 227 | * @param cs The string to convert to an {@link XPathExpression}. | |
| 228 | * @return {@code null} if there was an error compiling the xpath. | |
| 197 | 229 | */ |
| 198 | public static String decorate( final String html ) { | |
| 199 | return | |
| 200 | "<html><head><title> </title><meta charset='utf8'/></head><body>" | |
| 201 | + html | |
| 202 | + "</body></html>"; | |
| 203 | } | |
| 230 | public static XPathExpression compile( final CharSequence cs ) { | |
| 231 | assert cs != null; | |
| 204 | 232 | |
| 205 | private static XPathExpression lookupXPathExpression( final String xpath ) { | |
| 233 | final var xpath = cs.toString(); | |
| 234 | ||
| 206 | 235 | return sXpaths.computeIfAbsent( xpath, k -> { |
| 207 | 236 | try { |
| 208 | 237 | return sXpath.compile( xpath ); |
| 209 | 238 | } catch( final XPathExpressionException ex ) { |
| 210 | 239 | clue( ex ); |
| 211 | 240 | return null; |
| 212 | 241 | } |
| 213 | 242 | } ); |
| 214 | 243 | } |
| 244 | ||
| 245 | /** | |
| 246 | * Use the {@code static} constants and methods, not an instance, at least | |
| 247 | * until an iterable sub-interface is written. | |
| 248 | */ | |
| 249 | private DocumentParser() {} | |
| 215 | 250 | } |
| 216 | 251 | |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | * Requests the interpolated version of the variable definitions. | |
| 18 | * Requests all variable definitions. | |
| 19 | 19 | * |
| 20 | * @return The definition map with all variables interpolated. | |
| 20 | * @return The definition map without interpolation. | |
| 21 | 21 | */ |
| 22 | 22 | Map<String, String> getDefinitions(); |
| 23 | 23 | |
| 24 | 24 | /** |
| 25 | * Requests that the visual representation be expanded to the given | |
| 26 | * node. | |
| 25 | * Requests that the visual representation be expanded to the given node. | |
| 27 | 26 | * |
| 28 | 27 | * @param node Request expansion to this node. |
| 27 | 27 | |
| 28 | 28 | /** |
| 29 | * Requests that styling be added to the document between the given | |
| 30 | * integer values. | |
| 31 | * | |
| 32 | * @param indexes Document offset where style is to start and end. | |
| 33 | * @param style The style class to apply between the given offset indexes. | |
| 34 | */ | |
| 35 | default void stylize( final IndexRange indexes, final String style ) { | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Requests that the most recent styling for the given style class be | |
| 40 | * removed from the document between the given integer values. | |
| 41 | */ | |
| 42 | default void unstylize( final String style ) { | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | 29 | * Returns the complete text for the specified paragraph index. |
| 47 | 30 | * |
| ... | ||
| 149 | 132 | * Requests making the selected text, or word at caret, bold. |
| 150 | 133 | */ |
| 151 | default void bold() { } | |
| 134 | default void bold() {} | |
| 152 | 135 | |
| 153 | 136 | /** |
| 154 | 137 | * Requests making the selected text, or word at caret, italic. |
| 155 | 138 | */ |
| 156 | default void italic() { } | |
| 139 | default void italic() {} | |
| 157 | 140 | |
| 158 | 141 | /** |
| 159 | 142 | * Requests making the selected text, or word at caret, monospace. |
| 160 | 143 | */ |
| 161 | default void monospace() { } | |
| 144 | default void monospace() {} | |
| 162 | 145 | |
| 163 | 146 | /** |
| 164 | 147 | * Requests making the selected text, or word at caret, a superscript. |
| 165 | 148 | */ |
| 166 | default void superscript() { } | |
| 149 | default void superscript() {} | |
| 167 | 150 | |
| 168 | 151 | /** |
| 169 | 152 | * Requests making the selected text, or word at caret, a subscript. |
| 170 | 153 | */ |
| 171 | default void subscript() { } | |
| 154 | default void subscript() {} | |
| 172 | 155 | |
| 173 | 156 | /** |
| 174 | 157 | * Requests making the selected text, or word at caret, struck. |
| 175 | 158 | */ |
| 176 | default void strikethrough() { } | |
| 159 | default void strikethrough() {} | |
| 177 | 160 | |
| 178 | 161 | /** |
| 179 | 162 | * Requests making the selected text, or word at caret, a blockquote block. |
| 180 | 163 | */ |
| 181 | default void blockquote() { } | |
| 164 | default void blockquote() {} | |
| 182 | 165 | |
| 183 | 166 | /** |
| 184 | 167 | * Requests making the selected text, or word at caret, inline code. |
| 185 | 168 | */ |
| 186 | default void code() { } | |
| 169 | default void code() {} | |
| 187 | 170 | |
| 188 | 171 | /** |
| 189 | 172 | * Requests making the selected text, or word at caret, a fenced code block. |
| 190 | 173 | */ |
| 191 | default void fencedCodeBlock() { } | |
| 174 | default void fencedCodeBlock() {} | |
| 192 | 175 | |
| 193 | 176 | /** |
| 194 | 177 | * Requests making the selected text, or word at caret, a heading. |
| 195 | 178 | * |
| 196 | 179 | * @param level The heading level to apply (typically 1 through 3). |
| 197 | 180 | */ |
| 198 | default void heading( final int level ) { } | |
| 181 | default void heading( final int level ) {} | |
| 199 | 182 | |
| 200 | 183 | /** |
| 201 | 184 | * Requests making the selected text, or word at caret, an unordered list |
| 202 | 185 | * block. |
| 203 | 186 | */ |
| 204 | default void unorderedList() { } | |
| 187 | default void unorderedList() {} | |
| 205 | 188 | |
| 206 | 189 | /** |
| 207 | 190 | * Requests making the selected text, or word at caret, an ordered list block. |
| 208 | 191 | */ |
| 209 | default void orderedList() { } | |
| 192 | default void orderedList() {} | |
| 210 | 193 | |
| 211 | 194 | /** |
| 212 | 195 | * Requests making the selected text, or inserting at the caret, a |
| 213 | 196 | * horizontal rule. |
| 214 | 197 | */ |
| 215 | default void horizontalRule() { } | |
| 198 | default void horizontalRule() {} | |
| 199 | ||
| 200 | /** | |
| 201 | * Requests that styling be added to the document between the given | |
| 202 | * integer values. | |
| 203 | * | |
| 204 | * @param indexes Document offset where style is to start and end. | |
| 205 | * @param style The style class to apply between the given offset indexes. | |
| 206 | */ | |
| 207 | default void stylize( final IndexRange indexes, final String style ) {} | |
| 208 | ||
| 209 | /** | |
| 210 | * Requests that the most recent styling for the given style class be | |
| 211 | * removed from the document between the given integer values. | |
| 212 | */ | |
| 213 | default void unstylize( final String style ) {} | |
| 216 | 214 | } |
| 217 | 215 | |
| 5 | 5 | import com.keenwrite.editors.TextDefinition; |
| 6 | 6 | import com.keenwrite.events.TextDefinitionFocusEvent; |
| 7 | import com.keenwrite.sigils.SigilOperator; | |
| 7 | import com.keenwrite.processors.r.Engine; | |
| 8 | 8 | import com.keenwrite.ui.tree.AltTreeView; |
| 9 | 9 | import com.keenwrite.ui.tree.TreeItemConverter; |
| ... | ||
| 66 | 66 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers |
| 67 | 67 | = new HashSet<>(); |
| 68 | ||
| 69 | /** | |
| 70 | * File being edited by this editor instance. | |
| 71 | */ | |
| 72 | private File mFile; | |
| 73 | 68 | |
| 74 | 69 | private final Map<String, String> mDefinitions = new HashMap<>(); |
| ... | ||
| 85 | 80 | */ |
| 86 | 81 | private final BooleanProperty mModified = new SimpleBooleanProperty(); |
| 82 | ||
| 83 | /** | |
| 84 | * File being edited by this editor instance, which may be renamed. | |
| 85 | */ | |
| 86 | private File mFile; | |
| 87 | 87 | |
| 88 | 88 | /** |
| 89 | 89 | * This is provided for unit tests that are not backed by files. |
| 90 | 90 | * |
| 91 | 91 | * @param treeTransformer Responsible for transforming the definitions into |
| 92 | 92 | * {@link TreeItem} instances. |
| 93 | * @param operator Defines how detect variables within values so | |
| 94 | * that they are interpolated when returning the | |
| 95 | * definitions. | |
| 96 | 93 | */ |
| 97 | 94 | public DefinitionEditor( |
| 98 | final TreeTransformer treeTransformer, | |
| 99 | final SigilOperator operator ) { | |
| 100 | this( DEFINITION_DEFAULT, treeTransformer, operator ); | |
| 95 | final TreeTransformer treeTransformer ) { | |
| 96 | this( DEFINITION_DEFAULT, treeTransformer ); | |
| 101 | 97 | } |
| 102 | 98 | |
| 103 | 99 | /** |
| 104 | 100 | * Constructs a definition pane with a given tree view root. |
| 105 | 101 | * |
| 106 | 102 | * @param file The file of definitions to maintain through the UI. |
| 107 | 103 | */ |
| 108 | 104 | public DefinitionEditor( |
| 109 | 105 | final File file, |
| 110 | final TreeTransformer treeTransformer, | |
| 111 | final SigilOperator operator ) { | |
| 106 | final TreeTransformer treeTransformer ) { | |
| 112 | 107 | assert file != null; |
| 113 | 108 | assert treeTransformer != null; |
| ... | ||
| 129 | 124 | buttonBar.setAlignment( CENTER ); |
| 130 | 125 | buttonBar.setSpacing( UI_CONTROL_SPACING ); |
| 131 | ||
| 132 | 126 | setTop( buttonBar ); |
| 133 | 127 | setCenter( mTreeView ); |
| 134 | 128 | setAlignment( buttonBar, TOP_CENTER ); |
| 129 | ||
| 135 | 130 | mEncoding = open( mFile ); |
| 131 | updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | |
| 136 | 132 | |
| 137 | 133 | // After the file is opened, watch for changes, not before. Otherwise, |
| 138 | 134 | // upon saving, users will be prompted to save a file that hasn't had |
| 139 | 135 | // any modifications (from their perspective). |
| 140 | 136 | addTreeChangeHandler( event -> { |
| 141 | interpolate( operator ); | |
| 142 | 137 | mModified.set( true ); |
| 138 | updateDefinitions( getDefinitions(), getTreeView().getRoot() ); | |
| 143 | 139 | } ); |
| 140 | } | |
| 144 | 141 | |
| 145 | interpolate( operator ); | |
| 142 | /** | |
| 143 | * Replaces the given list of variable definitions with a flat hierarchy | |
| 144 | * of the converted {@link TreeView} root. | |
| 145 | * | |
| 146 | * @param definitions The definition map to update. | |
| 147 | * @param root The values to flatten then insert into the map. | |
| 148 | */ | |
| 149 | private void updateDefinitions( | |
| 150 | final Map<String, String> definitions, | |
| 151 | final TreeItem<String> root ) { | |
| 152 | definitions.clear(); | |
| 153 | definitions.putAll( TreeItemMapper.convert( root ) ); | |
| 154 | Engine.clear(); | |
| 146 | 155 | } |
| 147 | 156 | |
| 148 | 157 | /** |
| 149 | * Returns the variable definitions. This is called in critical parts of the | |
| 150 | * application, necessitating a cache. The cache is updated by calling | |
| 151 | * {@link #interpolate(SigilOperator)}, which happens upon tree modifications | |
| 152 | * via the editor or immediately after the definition file is loaded. | |
| 158 | * Returns the variable definitions. | |
| 153 | 159 | * |
| 154 | * @return The definition map with all variable references fully interpolated | |
| 155 | * and replaced. | |
| 160 | * @return The definition map. | |
| 156 | 161 | */ |
| 157 | 162 | @Override |
| ... | ||
| 181 | 186 | |
| 182 | 187 | problem.ifPresentOrElse( |
| 183 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 188 | node -> clue( "yaml.error.tree.form", node ), | |
| 184 | 189 | () -> result.append( mTreeTransformer.transform( root ) ) |
| 185 | 190 | ); |
| ... | ||
| 221 | 226 | public void clearModifiedProperty() { |
| 222 | 227 | mModified.setValue( false ); |
| 223 | } | |
| 224 | ||
| 225 | private void interpolate( final SigilOperator operator ) { | |
| 226 | final var map = TreeItemMapper.convert( getTreeView().getRoot() ); | |
| 227 | ||
| 228 | mDefinitions.clear(); | |
| 229 | mDefinitions.putAll( map.interpolate( operator ) ); | |
| 230 | 228 | } |
| 231 | 229 | |
| 3 | 3 | |
| 4 | 4 | import com.fasterxml.jackson.databind.JsonNode; |
| 5 | import com.keenwrite.util.InterpolatingMap; | |
| 6 | 5 | import javafx.scene.control.TreeItem; |
| 7 | 6 | |
| 7 | import java.util.HashMap; | |
| 8 | 8 | import java.util.Iterator; |
| 9 | import java.util.Map; | |
| 9 | 10 | import java.util.Stack; |
| 10 | 11 | |
| ... | ||
| 66 | 67 | * @param root The topmost item in the tree. |
| 67 | 68 | */ |
| 68 | public static InterpolatingMap convert( final TreeItem<String> root ) { | |
| 69 | final var map = new InterpolatingMap(); | |
| 69 | public static Map<String, String> convert( final TreeItem<String> root ) { | |
| 70 | final var map = new HashMap<String, String>(); | |
| 70 | 71 | |
| 71 | 72 | new TreeIterator( root ).forEachRemaining( item -> { |
| 72 | if( item.isLeaf() ) { | |
| 73 | if( item.isLeaf() && item.getParent() != null ) { | |
| 73 | 74 | map.put( toPath( item.getParent() ), item.getValue() ); |
| 74 | 75 | } |
| ... | ||
| 87 | 88 | */ |
| 88 | 89 | public static <T> String toPath( TreeItem<T> node ) { |
| 89 | assert node != null; | |
| 90 | ||
| 91 | 90 | final var key = new StringBuilder( DEFAULT_KEY_LENGTH ); |
| 92 | 91 | final var stack = new Stack<TreeItem<T>>(); |
| 5 | 5 | import com.keenwrite.constants.Constants; |
| 6 | 6 | import com.keenwrite.editors.TextEditor; |
| 7 | import com.keenwrite.events.TextEditorFocusEvent; | |
| 7 | 8 | import com.keenwrite.io.MediaType; |
| 8 | 9 | import com.keenwrite.preferences.LocaleProperty; |
| ... | ||
| 38 | 39 | import static com.keenwrite.constants.Constants.*; |
| 39 | 40 | import static com.keenwrite.events.StatusEvent.clue; |
| 40 | import static com.keenwrite.events.TextEditorFocusEvent.fireTextEditorFocus; | |
| 41 | 41 | import static com.keenwrite.io.MediaType.TEXT_MARKDOWN; |
| 42 | 42 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; |
| 43 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 43 | import static com.keenwrite.preferences.AppKeys.*; | |
| 44 | 44 | import static java.lang.Character.isWhitespace; |
| 45 | 45 | import static java.lang.String.format; |
| ... | ||
| 65 | 65 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( |
| 66 | 66 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); |
| 67 | ||
| 68 | private final Workspace mWorkspace; | |
| 67 | 69 | |
| 68 | 70 | /** |
| ... | ||
| 77 | 79 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = |
| 78 | 80 | new VirtualizedScrollPane<>( mTextArea ); |
| 79 | ||
| 80 | /** | |
| 81 | * | |
| 82 | */ | |
| 83 | private final TextEditorSpeller mSpeller = new TextEditorSpeller(); | |
| 84 | ||
| 85 | private final Workspace mWorkspace; | |
| 86 | 81 | |
| 87 | 82 | /** |
| 88 | 83 | * Tracks where the caret is located in this document. This offers observable |
| 89 | 84 | * properties for caret position changes. |
| 90 | 85 | */ |
| 91 | 86 | private final Caret mCaret = createCaret( mTextArea ); |
| 87 | ||
| 88 | /** | |
| 89 | * For spell checking the document upon load and whenever it changes. | |
| 90 | */ | |
| 91 | private final TextEditorSpeller mSpeller = new TextEditorSpeller(); | |
| 92 | 92 | |
| 93 | 93 | /** |
| ... | ||
| 139 | 139 | mDirty.set( false ); |
| 140 | 140 | |
| 141 | // Prevent a caret position change from raising the dirty bits. | |
| 141 | // Prevent the subsequent caret position change from raising dirty bits. | |
| 142 | 142 | mDirty.set( true ); |
| 143 | 143 | } ); |
| ... | ||
| 151 | 151 | textArea.focusedProperty().addListener( ( c, o, n ) -> { |
| 152 | 152 | if( n != null && n ) { |
| 153 | fireTextEditorFocus( this ); | |
| 153 | TextEditorFocusEvent.fire( this ); | |
| 154 | 154 | } |
| 155 | 155 | } ); |
| 2 | 2 | package com.keenwrite.events; |
| 3 | 3 | |
| 4 | import com.keenwrite.MainApp; | |
| 4 | import com.keenwrite.AppCommands; | |
| 5 | 5 | |
| 6 | 6 | import java.util.List; |
| 7 | import java.util.stream.Collectors; | |
| 8 | 7 | |
| 9 | 8 | import static com.keenwrite.Messages.get; |
| ... | ||
| 19 | 18 | */ |
| 20 | 19 | public final class StatusEvent implements AppEvent { |
| 21 | private static final String PACKAGE_NAME = MainApp.class.getPackageName(); | |
| 20 | /** | |
| 21 | * Reference a class in the top-level package that doesn't depend on any | |
| 22 | * JavaFX APIs. | |
| 23 | */ | |
| 24 | private static final String PACKAGE_NAME = AppCommands.class.getPackageName(); | |
| 22 | 25 | |
| 23 | 26 | private static final String ENGLISHIFY = |
| ... | ||
| 73 | 76 | .takeWhile( StatusEvent::filter ) |
| 74 | 77 | .limit( 10 ) |
| 75 | .collect( Collectors.toList() ) | |
| 78 | .toList() | |
| 76 | 79 | .forEach( e -> sb.append( e.toString() ).append( NEWLINE ) ); |
| 77 | 80 | } |
| 15 | 15 | * @param editor The instance of editor that has gained input focus. |
| 16 | 16 | */ |
| 17 | public static void fireTextEditorFocus( final TextEditor editor ) { | |
| 17 | public static void fire( final TextEditor editor ) { | |
| 18 | 18 | new TextEditorFocusEvent( editor ).publish(); |
| 19 | 19 | } |
| 98 | 98 | /* |
| 99 | 99 | * Document types for editing or displaying documents, mix of standard and |
| 100 | * application-specific. | |
| 100 | * application-specific. The order that these are declared reflect in the | |
| 101 | * ordinal value used during comparisons. | |
| 101 | 102 | */ |
| 102 | TEXT_HTML( TEXT, "html" ), | |
| 103 | TEXT_MARKDOWN( TEXT, "markdown" ), | |
| 103 | TEXT_YAML( TEXT, "yaml" ), | |
| 104 | 104 | TEXT_PLAIN( TEXT, "plain" ), |
| 105 | TEXT_MARKDOWN( TEXT, "markdown" ), | |
| 105 | 106 | TEXT_R_MARKDOWN( TEXT, "R+markdown" ), |
| 107 | TEXT_PROPERTIES( TEXT, "x-java-properties" ), | |
| 108 | TEXT_HTML( TEXT, "html" ), | |
| 106 | 109 | TEXT_XHTML( TEXT, "xhtml+xml" ), |
| 107 | 110 | TEXT_XML( TEXT, "xml" ), |
| 108 | TEXT_YAML( TEXT, "yaml" ), | |
| 109 | 111 | |
| 110 | 112 | /* |
| 46 | 46 | MEDIA_TEXT_PLAIN( TEXT_PLAIN, of( "txt", "asc", "ascii", "text", "utxt" ) ), |
| 47 | 47 | MEDIA_TEXT_R_MARKDOWN( TEXT_R_MARKDOWN, of( "Rmd" ) ), |
| 48 | MEDIA_TEXT_PROPERTIES( TEXT_PROPERTIES, of( "properties" ) ), | |
| 48 | 49 | MEDIA_TEXT_XHTML( TEXT_XHTML, of( "xhtml" ) ), |
| 49 | 50 | MEDIA_TEXT_XML( TEXT_XML ), |
| 50 | 51 | MEDIA_TEXT_YAML( TEXT_YAML, of( "yaml", "yml" ) ), |
| 51 | 52 | |
| 52 | 53 | MEDIA_UNDEFINED( UNDEFINED, of( "undefined" ) ); |
| 54 | ||
| 55 | /** | |
| 56 | * Returns the {@link MediaTypeExtension} that matches the given media type. | |
| 57 | * | |
| 58 | * @param mediaType The media type to find. | |
| 59 | * @return The correlated value or {@link #MEDIA_UNDEFINED} if not found. | |
| 60 | */ | |
| 61 | public static MediaTypeExtension valueFrom( final MediaType mediaType ) { | |
| 62 | for( final var type : values() ) { | |
| 63 | if( type.isMediaType( mediaType ) ) { | |
| 64 | return type; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | return MEDIA_UNDEFINED; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Returns the {@link MediaType} associated with the given file name | |
| 73 | * extension. The extension must not contain a period. | |
| 74 | * | |
| 75 | * @param extension File name extension, case insensitive, {@code null}-safe. | |
| 76 | * @return The associated {@link MediaType} as defined by IANA. | |
| 77 | */ | |
| 78 | static MediaType getMediaType( final String extension ) { | |
| 79 | final var sanitized = sanitize( extension ); | |
| 80 | ||
| 81 | for( final var mediaType : MediaTypeExtension.values() ) { | |
| 82 | if( mediaType.isType( sanitized ) ) { | |
| 83 | return mediaType.getMediaType(); | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | return UNDEFINED; | |
| 88 | } | |
| 89 | ||
| 90 | private static String sanitize( final String extension ) { | |
| 91 | return extension == null ? "" : extension.toLowerCase(); | |
| 92 | } | |
| 53 | 93 | |
| 54 | 94 | private final MediaType mMediaType; |
| ... | ||
| 95 | 135 | public String getExtension() { |
| 96 | 136 | return mExtensions.get( 0 ); |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * Returns the {@link MediaTypeExtension} that matches the given media type. | |
| 101 | * | |
| 102 | * @param mediaType The media type to find. | |
| 103 | * @return The correlated value or {@link #MEDIA_UNDEFINED} if not found. | |
| 104 | */ | |
| 105 | public static MediaTypeExtension valueFrom( final MediaType mediaType ) { | |
| 106 | for( final var type : values() ) { | |
| 107 | if( type.isMediaType( mediaType ) ) { | |
| 108 | return type; | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | return MEDIA_UNDEFINED; | |
| 113 | 137 | } |
| 114 | 138 | |
| 115 | 139 | boolean isMediaType( final MediaType mediaType ) { |
| 116 | 140 | return mMediaType == mediaType; |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Returns the {@link MediaType} associated with the given file name | |
| 121 | * extension. The extension must not contain a period. | |
| 122 | * | |
| 123 | * @param extension File name extension, case insensitive, {@code null}-safe. | |
| 124 | * @return The associated {@link MediaType} as defined by IANA. | |
| 125 | */ | |
| 126 | static MediaType getMediaType( final String extension ) { | |
| 127 | final var sanitized = sanitize( extension ); | |
| 128 | ||
| 129 | for( final var mediaType : MediaTypeExtension.values() ) { | |
| 130 | if( mediaType.isType( sanitized ) ) { | |
| 131 | return mediaType.getMediaType(); | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | return UNDEFINED; | |
| 136 | 141 | } |
| 137 | 142 | |
| ... | ||
| 144 | 149 | |
| 145 | 150 | return false; |
| 146 | } | |
| 147 | ||
| 148 | private static String sanitize( final String extension ) { | |
| 149 | return extension == null ? "" : extension.toLowerCase(); | |
| 150 | 151 | } |
| 151 | 152 | |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | * Creates a new instance for a given file name. | |
| 23 | * | |
| 24 | * @param pathname File name to represent for subsequent operations. | |
| 25 | */ | |
| 26 | public SysFile( final String pathname ) { | |
| 27 | super( pathname ); | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | 22 | * For a file name that represents an executable (without an extension) |
| 32 | 23 | * file, this determines whether the executable is found in the PATH |
| ... | ||
| 55 | 46 | } |
| 56 | 47 | ); |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Creates a new instance for a given file name. | |
| 52 | * | |
| 53 | * @param pathname File name to represent for subsequent operations. | |
| 54 | */ | |
| 55 | public SysFile( final String pathname ) { | |
| 56 | super( pathname ); | |
| 57 | 57 | } |
| 58 | 58 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import static com.keenwrite.preferences.Key.key; | |
| 5 | ||
| 6 | /** | |
| 7 | * Responsible for defining constants used throughout the application that | |
| 8 | * represent persisted preferences. | |
| 9 | */ | |
| 10 | public final class AppKeys { | |
| 11 | //@formatter:off | |
| 12 | private static final Key KEY_ROOT = key( "workspace" ); | |
| 13 | ||
| 14 | public static final Key KEY_META = key( KEY_ROOT, "meta" ); | |
| 15 | public static final Key KEY_META_NAME = key( KEY_META, "name" ); | |
| 16 | public static final Key KEY_META_VERSION = key( KEY_META, "version" ); | |
| 17 | ||
| 18 | public static final Key KEY_DOC = key( KEY_ROOT, "document" ); | |
| 19 | public static final Key KEY_DOC_META = key( KEY_DOC, "meta" ); | |
| 20 | ||
| 21 | public static final Key KEY_EDITOR = key( KEY_ROOT, "editor" ); | |
| 22 | public static final Key KEY_EDITOR_AUTOSAVE = key( KEY_EDITOR, "autosave" ); | |
| 23 | ||
| 24 | public static final Key KEY_R = key( KEY_ROOT, "r" ); | |
| 25 | public static final Key KEY_R_SCRIPT = key( KEY_R, "script" ); | |
| 26 | public static final Key KEY_R_DIR = key( KEY_R, "dir" ); | |
| 27 | public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" ); | |
| 28 | public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" ); | |
| 29 | public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" ); | |
| 30 | ||
| 31 | public static final Key KEY_IMAGES = key( KEY_ROOT, "images" ); | |
| 32 | public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" ); | |
| 33 | public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" ); | |
| 34 | public static final Key KEY_IMAGES_RESIZE = key( KEY_IMAGES, "resize" ); | |
| 35 | public static final Key KEY_IMAGES_SERVER = key( KEY_IMAGES, "server" ); | |
| 36 | ||
| 37 | public static final Key KEY_DEF = key( KEY_ROOT, "definition" ); | |
| 38 | public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" ); | |
| 39 | public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" ); | |
| 40 | public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" ); | |
| 41 | public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" ); | |
| 42 | ||
| 43 | public static final Key KEY_UI = key( KEY_ROOT, "ui" ); | |
| 44 | ||
| 45 | public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" ); | |
| 46 | public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" ); | |
| 47 | public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" ); | |
| 48 | public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" ); | |
| 49 | public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" ); | |
| 50 | public static final Key KEY_UI_RECENT_OPEN = key( KEY_UI_RECENT, "files" ); | |
| 51 | public static final Key KEY_UI_RECENT_OPEN_PATH = key( KEY_UI_RECENT_OPEN, "path" ); | |
| 52 | ||
| 53 | public static final Key KEY_UI_FONT = key( KEY_UI, "font" ); | |
| 54 | public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" ); | |
| 55 | public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" ); | |
| 56 | public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" ); | |
| 57 | public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" ); | |
| 58 | public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" ); | |
| 59 | public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" ); | |
| 60 | public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" ); | |
| 61 | public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" ); | |
| 62 | public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" ); | |
| 63 | ||
| 64 | public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" ); | |
| 65 | public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" ); | |
| 66 | public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" ); | |
| 67 | public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" ); | |
| 68 | public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" ); | |
| 69 | public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" ); | |
| 70 | public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" ); | |
| 71 | ||
| 72 | public static final Key KEY_UI_SKIN = key( KEY_UI, "skin" ); | |
| 73 | public static final Key KEY_UI_SKIN_SELECTION = key( KEY_UI_SKIN, "selection" ); | |
| 74 | public static final Key KEY_UI_SKIN_CUSTOM = key( KEY_UI_SKIN, "custom" ); | |
| 75 | ||
| 76 | public static final Key KEY_UI_PREVIEW = key( KEY_UI, "preview" ); | |
| 77 | public static final Key KEY_UI_PREVIEW_STYLESHEET = key( KEY_UI_PREVIEW, "stylesheet" ); | |
| 78 | ||
| 79 | public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" ); | |
| 80 | public static final Key KEY_LANGUAGE_LOCALE = key( KEY_LANGUAGE, "locale" ); | |
| 81 | ||
| 82 | public static final Key KEY_TYPESET = key( KEY_ROOT, "typeset" ); | |
| 83 | public static final Key KEY_TYPESET_CONTEXT = key( KEY_TYPESET, "context" ); | |
| 84 | public static final Key KEY_TYPESET_CONTEXT_THEMES = key( KEY_TYPESET_CONTEXT, "themes" ); | |
| 85 | public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" ); | |
| 86 | public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" ); | |
| 87 | public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" ); | |
| 88 | public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" ); | |
| 89 | public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" ); | |
| 90 | //@formatter:on | |
| 91 | ||
| 92 | /** | |
| 93 | * Only for constants, do not instantiate. | |
| 94 | */ | |
| 95 | private AppKeys() { } | |
| 96 | } | |
| 1 | 97 |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 | |
| 4 | import java.util.Stack; | |
| 5 | import java.util.function.Consumer; | |
| 6 | ||
| 4 | 7 | /** |
| 5 | 8 | * Responsible for creating a type hierarchy of preference storage keys. |
| 6 | 9 | */ |
| 7 | 10 | public class Key { |
| 8 | 11 | private final Key mParent; |
| 9 | 12 | private final String mName; |
| 10 | ||
| 11 | private Key( final Key parent, final String name ) { | |
| 12 | mParent = parent; | |
| 13 | mName = name; | |
| 14 | } | |
| 15 | 13 | |
| 16 | 14 | /** |
| 17 | 15 | * Returns a new key with no parent. |
| 18 | 16 | * |
| 19 | 17 | * @param name The key name, never {@code null}. |
| 20 | 18 | * @return The new {@link Key} instance with a name but no parent. |
| 21 | 19 | */ |
| 22 | 20 | public static Key key( final String name ) { |
| 23 | assert name != null && !name.isEmpty(); | |
| 24 | 21 | return key( null, name ); |
| 25 | 22 | } |
| ... | ||
| 34 | 31 | */ |
| 35 | 32 | public static Key key( final Key parent, final String name ) { |
| 36 | assert name != null && !name.isEmpty(); | |
| 37 | 33 | return new Key( parent, name ); |
| 38 | 34 | } |
| 39 | 35 | |
| 40 | private Key parent() { | |
| 36 | private Key( final Key parent, final String name ) { | |
| 37 | assert name != null; | |
| 38 | assert !name.isBlank(); | |
| 39 | ||
| 40 | mParent = parent; | |
| 41 | mName = name; | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Answers whether more {@link Key}s exist above this one in the hierarchy. | |
| 46 | * | |
| 47 | * @return {@code true} means this {@link Key} has a parent {@link Key}. | |
| 48 | */ | |
| 49 | public boolean hasParent() { | |
| 50 | return mParent != null; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Visits every key in the hierarchy, starting at the topmost {@link Key} and | |
| 55 | * ending with the current {@link Key}. | |
| 56 | * | |
| 57 | * @param consumer Receives the name of each visited node. | |
| 58 | * @param separator Characters to insert between each node. | |
| 59 | */ | |
| 60 | public void walk( final Consumer<String> consumer, final String separator ) { | |
| 61 | var key = this; | |
| 62 | ||
| 63 | final var stack = new Stack<String>(); | |
| 64 | ||
| 65 | while( key != null ) { | |
| 66 | stack.push( key.name() ); | |
| 67 | key = key.parent(); | |
| 68 | } | |
| 69 | ||
| 70 | var sep = ""; | |
| 71 | ||
| 72 | while( !stack.empty() ) { | |
| 73 | consumer.accept( sep + stack.pop() ); | |
| 74 | sep = separator; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | public void walk( final Consumer<String> consumer ) { | |
| 79 | walk( consumer, "" ); | |
| 80 | } | |
| 81 | ||
| 82 | public Key parent() { | |
| 41 | 83 | return mParent; |
| 42 | 84 | } |
| 43 | 85 | |
| 44 | private String name() { | |
| 86 | public String name() { | |
| 45 | 87 | return mName; |
| 46 | 88 | } |
| 47 | 89 | |
| 48 | 90 | /** |
| 49 | 91 | * Returns a dot-separated path representing the key's name. |
| 50 | 92 | * |
| 51 | * @return The recursively derived dot-separated key name. | |
| 93 | * @return The dot-separated key name. | |
| 52 | 94 | */ |
| 53 | 95 | @Override |
| 54 | 96 | public String toString() { |
| 55 | return parent() == null ? name() : parent().toString() + '.' + name(); | |
| 97 | final var sb = new StringBuilder( 128 ); | |
| 98 | ||
| 99 | walk( sb::append, "." ); | |
| 100 | ||
| 101 | return sb.toString(); | |
| 56 | 102 | } |
| 57 | 103 | } |
| 1 | package com.keenwrite.preferences; | |
| 2 | ||
| 3 | import com.keenwrite.cmdline.Arguments; | |
| 4 | ||
| 5 | import java.io.File; | |
| 6 | ||
| 7 | /** | |
| 8 | * Responsible for maintaining key-value pairs for user-defined setting | |
| 9 | * values. When processing a document, various settings are used to configure | |
| 10 | * the processing behaviour. This interface represents an abstraction that | |
| 11 | * can be used by the processors without having to depend on a specific | |
| 12 | * implementation, such as {@link Arguments} or a {@link Workspace}. | |
| 13 | */ | |
| 14 | public interface KeyConfiguration { | |
| 15 | ||
| 16 | /** | |
| 17 | * Returns a {@link String} value associated with the given {@link Key}. | |
| 18 | * | |
| 19 | * @param key The {@link Key} associated with a value. | |
| 20 | * @return The value associated with the given {@link Key}. | |
| 21 | */ | |
| 22 | String getString( final Key key ); | |
| 23 | ||
| 24 | /** | |
| 25 | * Returns a {@link Boolean} value associated with the given {@link Key}. | |
| 26 | * | |
| 27 | * @param key The {@link Key} associated with a value. | |
| 28 | * @return The value associated with the given {@link Key}. | |
| 29 | */ | |
| 30 | boolean getBoolean( final Key key ); | |
| 31 | ||
| 32 | /** | |
| 33 | * Returns an {@link Integer} value associated with the given {@link Key}. | |
| 34 | * | |
| 35 | * @param key The {@link Key} associated with a value. | |
| 36 | * @return The value associated with the given {@link Key}. | |
| 37 | */ | |
| 38 | int getInteger( final Key key ); | |
| 39 | ||
| 40 | /** | |
| 41 | * Returns a {@link Double} value associated with the given {@link Key}. | |
| 42 | * | |
| 43 | * @param key The {@link Key} associated with a value. | |
| 44 | * @return The value associated with the given {@link Key}. | |
| 45 | */ | |
| 46 | double getDouble( final Key key ); | |
| 47 | ||
| 48 | /** | |
| 49 | * Returns a {@link File} value associated with the given {@link Key}. | |
| 50 | * | |
| 51 | * @param key The {@link Key} associated with a value. | |
| 52 | * @return The value associated with the given {@link Key}. | |
| 53 | */ | |
| 54 | File getFile( final Key key ); | |
| 55 | } | |
| 1 | 56 |
| 88 | 88 | private static Map<String, String> m( final String v, final String... k ) { |
| 89 | 89 | final var map = new HashMap<String, String>(); |
| 90 | asList( k ).forEach( ( key ) -> map.put( key, v ) ); | |
| 90 | asList( k ).forEach( key -> map.put( key, v ) ); | |
| 91 | 91 | return Collections.unmodifiableMap( map ); |
| 92 | 92 | } |
| 4 | 4 | import com.dlsc.formsfx.model.structure.StringField; |
| 5 | 5 | import com.dlsc.preferencesfx.PreferencesFx; |
| 6 | import com.dlsc.preferencesfx.PreferencesFxEvent; | |
| 7 | import com.dlsc.preferencesfx.model.Category; | |
| 8 | import com.dlsc.preferencesfx.model.Group; | |
| 9 | import com.dlsc.preferencesfx.model.Setting; | |
| 10 | import com.dlsc.preferencesfx.util.StorageHandler; | |
| 11 | import com.dlsc.preferencesfx.view.NavigationView; | |
| 12 | import javafx.beans.property.*; | |
| 13 | import javafx.event.EventHandler; | |
| 14 | import javafx.scene.Node; | |
| 15 | import javafx.scene.control.Button; | |
| 16 | import javafx.scene.control.DialogPane; | |
| 17 | import javafx.scene.control.Label; | |
| 18 | import org.controlsfx.control.MasterDetailPane; | |
| 19 | ||
| 20 | import java.io.File; | |
| 21 | ||
| 22 | import static com.dlsc.formsfx.model.structure.Field.ofStringType; | |
| 23 | import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED; | |
| 24 | import static com.keenwrite.Messages.get; | |
| 25 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; | |
| 26 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; | |
| 27 | import static com.keenwrite.preferences.SkinProperty.skinListProperty; | |
| 28 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 29 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 30 | import static javafx.scene.control.ButtonType.OK; | |
| 31 | ||
| 32 | /** | |
| 33 | * Provides the ability for users to configure their preferences. This links | |
| 34 | * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC. | |
| 35 | */ | |
| 36 | @SuppressWarnings( "SameParameterValue" ) | |
| 37 | public final class PreferencesController { | |
| 38 | ||
| 39 | private final Workspace mWorkspace; | |
| 40 | private final PreferencesFx mPreferencesFx; | |
| 41 | ||
| 42 | public PreferencesController( final Workspace workspace ) { | |
| 43 | mWorkspace = workspace; | |
| 44 | ||
| 45 | // All properties must be initialized before creating the dialog. | |
| 46 | mPreferencesFx = createPreferencesFx(); | |
| 47 | ||
| 48 | initKeyEventHandler( mPreferencesFx ); | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Display the user preferences settings dialog (non-modal). | |
| 53 | */ | |
| 54 | public void show() { | |
| 55 | getPreferencesFx().show( false ); | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Call to persist the settings. Strictly speaking, this could watch on | |
| 60 | * all values for external changes then save automatically. | |
| 61 | */ | |
| 62 | public void save() { | |
| 63 | getPreferencesFx().saveSettings(); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Delegates to the {@link PreferencesFx} event handler for monitoring | |
| 68 | * save events. | |
| 69 | * | |
| 70 | * @param eventHandler The handler to call when the preferences are saved. | |
| 71 | */ | |
| 72 | public void addSaveEventHandler( | |
| 73 | final EventHandler<? super PreferencesFxEvent> eventHandler ) { | |
| 74 | getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler ); | |
| 75 | } | |
| 76 | ||
| 77 | private StringField createFontNameField( | |
| 78 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 79 | final var control = new SimpleFontControl( "Change" ); | |
| 80 | control.fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 81 | if( n != null ) { | |
| 82 | fontSize.set( n.doubleValue() ); | |
| 83 | } | |
| 84 | } ); | |
| 85 | return ofStringType( fontName ).render( control ); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Creates the preferences dialog based using {@link XmlStorageHandler} and | |
| 90 | * numerous {@link Category} objects. | |
| 91 | * | |
| 92 | * @return A component for editing preferences. | |
| 93 | * @throws RuntimeException Could not construct the {@link PreferencesFx} | |
| 94 | * object (e.g., illegal access permissions, | |
| 95 | * unmapped XML resource). | |
| 96 | */ | |
| 97 | private PreferencesFx createPreferencesFx() { | |
| 98 | return PreferencesFx.of( createStorageHandler(), createCategories() ) | |
| 99 | .instantPersistent( false ) | |
| 100 | .dialogIcon( ICON_DIALOG ); | |
| 101 | } | |
| 102 | ||
| 103 | private StorageHandler createStorageHandler() { | |
| 104 | return new XmlStorageHandler(); | |
| 105 | } | |
| 106 | ||
| 107 | private Category[] createCategories() { | |
| 108 | return new Category[]{ | |
| 109 | Category.of( | |
| 110 | get( KEY_DOC ), | |
| 111 | Group.of( | |
| 112 | get( KEY_DOC_TITLE ), | |
| 113 | Setting.of( label( KEY_DOC_TITLE ) ), | |
| 114 | Setting.of( title( KEY_DOC_TITLE ), | |
| 115 | stringProperty( KEY_DOC_TITLE ) ) | |
| 116 | ), | |
| 117 | Group.of( | |
| 118 | get( KEY_DOC_AUTHOR ), | |
| 119 | Setting.of( label( KEY_DOC_AUTHOR ) ), | |
| 120 | Setting.of( title( KEY_DOC_AUTHOR ), | |
| 121 | stringProperty( KEY_DOC_AUTHOR ) ) | |
| 122 | ), | |
| 123 | Group.of( | |
| 124 | get( KEY_DOC_BYLINE ), | |
| 125 | Setting.of( label( KEY_DOC_BYLINE ) ), | |
| 126 | Setting.of( title( KEY_DOC_BYLINE ), | |
| 127 | stringProperty( KEY_DOC_BYLINE ) ) | |
| 128 | ), | |
| 129 | Group.of( | |
| 130 | get( KEY_DOC_ADDRESS ), | |
| 131 | Setting.of( label( KEY_DOC_ADDRESS ) ), | |
| 132 | createMultilineSetting( "Address", KEY_DOC_ADDRESS ) | |
| 133 | ), | |
| 134 | Group.of( | |
| 135 | get( KEY_DOC_PHONE ), | |
| 136 | Setting.of( label( KEY_DOC_PHONE ) ), | |
| 137 | Setting.of( title( KEY_DOC_PHONE ), | |
| 138 | stringProperty( KEY_DOC_PHONE ) ) | |
| 139 | ), | |
| 140 | Group.of( | |
| 141 | get( KEY_DOC_EMAIL ), | |
| 142 | Setting.of( label( KEY_DOC_EMAIL ) ), | |
| 143 | Setting.of( title( KEY_DOC_EMAIL ), | |
| 144 | stringProperty( KEY_DOC_EMAIL ) ) | |
| 145 | ), | |
| 146 | Group.of( | |
| 147 | get( KEY_DOC_KEYWORDS ), | |
| 148 | Setting.of( label( KEY_DOC_KEYWORDS ) ), | |
| 149 | Setting.of( title( KEY_DOC_KEYWORDS ), | |
| 150 | stringProperty( KEY_DOC_KEYWORDS ) ) | |
| 151 | ), | |
| 152 | Group.of( | |
| 153 | get( KEY_DOC_COPYRIGHT ), | |
| 154 | Setting.of( label( KEY_DOC_COPYRIGHT ) ), | |
| 155 | Setting.of( title( KEY_DOC_COPYRIGHT ), | |
| 156 | stringProperty( KEY_DOC_COPYRIGHT ) ) | |
| 157 | ), | |
| 158 | Group.of( | |
| 159 | get( KEY_DOC_DATE ), | |
| 160 | Setting.of( label( KEY_DOC_DATE ) ), | |
| 161 | Setting.of( title( KEY_DOC_DATE ), | |
| 162 | stringProperty( KEY_DOC_DATE ) ) | |
| 163 | ) | |
| 164 | ), | |
| 165 | Category.of( | |
| 166 | get( KEY_TYPESET ), | |
| 167 | Group.of( | |
| 168 | get( KEY_TYPESET_CONTEXT ), | |
| 169 | Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | |
| 170 | Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 171 | fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ), | |
| 172 | Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | |
| 173 | Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | |
| 174 | booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 175 | ), | |
| 176 | Group.of( | |
| 177 | get( KEY_TYPESET_TYPOGRAPHY ), | |
| 178 | Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ), | |
| 179 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | |
| 180 | booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 181 | ) | |
| 182 | ), | |
| 183 | Category.of( | |
| 184 | get( KEY_EDITOR ), | |
| 185 | Group.of( | |
| 186 | get( KEY_EDITOR_AUTOSAVE ), | |
| 187 | Setting.of( label( KEY_EDITOR_AUTOSAVE ) ), | |
| 188 | Setting.of( title( KEY_EDITOR_AUTOSAVE ), | |
| 189 | integerProperty( KEY_EDITOR_AUTOSAVE ) ) | |
| 190 | ) | |
| 191 | ), | |
| 192 | Category.of( | |
| 193 | get( KEY_R ), | |
| 194 | Group.of( | |
| 195 | get( KEY_R_DIR ), | |
| 196 | Setting.of( label( KEY_R_DIR, | |
| 197 | stringProperty( KEY_DEF_DELIM_BEGAN ).get(), | |
| 198 | stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ), | |
| 199 | Setting.of( title( KEY_R_DIR ), | |
| 200 | fileProperty( KEY_R_DIR ), true ) | |
| 201 | ), | |
| 202 | Group.of( | |
| 203 | get( KEY_R_SCRIPT ), | |
| 204 | Setting.of( label( KEY_R_SCRIPT ) ), | |
| 205 | createMultilineSetting( "Script", KEY_R_SCRIPT ) | |
| 206 | ), | |
| 207 | Group.of( | |
| 208 | get( KEY_R_DELIM_BEGAN ), | |
| 209 | Setting.of( label( KEY_R_DELIM_BEGAN ) ), | |
| 210 | Setting.of( title( KEY_R_DELIM_BEGAN ), | |
| 211 | stringProperty( KEY_R_DELIM_BEGAN ) ) | |
| 212 | ), | |
| 213 | Group.of( | |
| 214 | get( KEY_R_DELIM_ENDED ), | |
| 215 | Setting.of( label( KEY_R_DELIM_ENDED ) ), | |
| 216 | Setting.of( title( KEY_R_DELIM_ENDED ), | |
| 217 | stringProperty( KEY_R_DELIM_ENDED ) ) | |
| 218 | ) | |
| 219 | ), | |
| 220 | Category.of( | |
| 221 | get( KEY_IMAGES ), | |
| 222 | Group.of( | |
| 223 | get( KEY_IMAGES_DIR ), | |
| 224 | Setting.of( label( KEY_IMAGES_DIR ) ), | |
| 225 | Setting.of( title( KEY_IMAGES_DIR ), | |
| 226 | fileProperty( KEY_IMAGES_DIR ), true ) | |
| 227 | ), | |
| 228 | Group.of( | |
| 229 | get( KEY_IMAGES_ORDER ), | |
| 230 | Setting.of( label( KEY_IMAGES_ORDER ) ), | |
| 231 | Setting.of( title( KEY_IMAGES_ORDER ), | |
| 232 | stringProperty( KEY_IMAGES_ORDER ) ) | |
| 233 | ), | |
| 234 | Group.of( | |
| 235 | get( KEY_IMAGES_RESIZE ), | |
| 236 | Setting.of( label( KEY_IMAGES_RESIZE ) ), | |
| 237 | Setting.of( title( KEY_IMAGES_RESIZE ), | |
| 238 | booleanProperty( KEY_IMAGES_RESIZE ) ) | |
| 239 | ), | |
| 240 | Group.of( | |
| 241 | get( KEY_IMAGES_SERVER ), | |
| 242 | Setting.of( label( KEY_IMAGES_SERVER ) ), | |
| 243 | Setting.of( title( KEY_IMAGES_SERVER ), | |
| 244 | stringProperty( KEY_IMAGES_SERVER ) ) | |
| 245 | ) | |
| 246 | ), | |
| 247 | Category.of( | |
| 248 | get( KEY_DEF ), | |
| 249 | Group.of( | |
| 250 | get( KEY_DEF_PATH ), | |
| 251 | Setting.of( label( KEY_DEF_PATH ) ), | |
| 252 | Setting.of( title( KEY_DEF_PATH ), | |
| 253 | fileProperty( KEY_DEF_PATH ), false ) | |
| 254 | ), | |
| 255 | Group.of( | |
| 256 | get( KEY_DEF_DELIM_BEGAN ), | |
| 257 | Setting.of( label( KEY_DEF_DELIM_BEGAN ) ), | |
| 258 | Setting.of( title( KEY_DEF_DELIM_BEGAN ), | |
| 259 | stringProperty( KEY_DEF_DELIM_BEGAN ) ) | |
| 260 | ), | |
| 261 | Group.of( | |
| 262 | get( KEY_DEF_DELIM_ENDED ), | |
| 263 | Setting.of( label( KEY_DEF_DELIM_ENDED ) ), | |
| 264 | Setting.of( title( KEY_DEF_DELIM_ENDED ), | |
| 265 | stringProperty( KEY_DEF_DELIM_ENDED ) ) | |
| 266 | ) | |
| 267 | ), | |
| 268 | Category.of( | |
| 269 | get( KEY_UI_FONT ), | |
| 270 | Group.of( | |
| 271 | get( KEY_UI_FONT_EDITOR ), | |
| 272 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 273 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 274 | createFontNameField( | |
| 275 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 276 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 277 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 278 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 279 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), | |
| 280 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) | |
| 281 | ), | |
| 282 | Group.of( | |
| 283 | get( KEY_UI_FONT_PREVIEW ), | |
| 284 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 285 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 286 | createFontNameField( | |
| 287 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 288 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 289 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 290 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 291 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 292 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 293 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 294 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 295 | createFontNameField( | |
| 296 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 297 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 298 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 299 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 300 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 301 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 302 | ) | |
| 303 | ), | |
| 304 | Category.of( | |
| 305 | get( KEY_UI_SKIN ), | |
| 306 | Group.of( | |
| 307 | get( KEY_UI_SKIN_SELECTION ), | |
| 308 | Setting.of( label( KEY_UI_SKIN_SELECTION ) ), | |
| 309 | Setting.of( title( KEY_UI_SKIN_SELECTION ), | |
| 310 | skinListProperty(), | |
| 311 | skinProperty( KEY_UI_SKIN_SELECTION ) ) | |
| 312 | ), | |
| 313 | Group.of( | |
| 314 | get( KEY_UI_SKIN_CUSTOM ), | |
| 315 | Setting.of( label( KEY_UI_SKIN_CUSTOM ) ), | |
| 316 | Setting.of( title( KEY_UI_SKIN_CUSTOM ), | |
| 317 | fileProperty( KEY_UI_SKIN_CUSTOM ), false ) | |
| 318 | ) | |
| 319 | ), | |
| 320 | Category.of( | |
| 321 | get( KEY_UI_PREVIEW ), | |
| 322 | Group.of( | |
| 323 | get( KEY_UI_PREVIEW_STYLESHEET ), | |
| 324 | Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ), | |
| 325 | Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ), | |
| 326 | fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false ) | |
| 327 | ) | |
| 328 | ), | |
| 329 | Category.of( | |
| 330 | get( KEY_LANGUAGE ), | |
| 331 | Group.of( | |
| 332 | get( KEY_LANGUAGE_LOCALE ), | |
| 333 | Setting.of( label( KEY_LANGUAGE_LOCALE ) ), | |
| 334 | Setting.of( title( KEY_LANGUAGE_LOCALE ), | |
| 335 | localeListProperty(), | |
| 336 | localeProperty( KEY_LANGUAGE_LOCALE ) ) | |
| 337 | ) | |
| 338 | )}; | |
| 339 | } | |
| 340 | ||
| 341 | @SuppressWarnings( "unchecked" ) | |
| 342 | private Setting<StringField, StringProperty> createMultilineSetting( | |
| 343 | final String description, final Key property ) { | |
| 344 | final Setting<StringField, StringProperty> setting = | |
| 345 | Setting.of( description, stringProperty( property ) ); | |
| 346 | final var field = setting.getElement(); | |
| 347 | field.multiline( true ); | |
| 348 | ||
| 349 | return setting; | |
| 350 | } | |
| 351 | ||
| 352 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 353 | final var view = preferences.getView(); | |
| 354 | final var nodes = view.getChildrenUnmodifiable(); | |
| 355 | final var master = (MasterDetailPane) nodes.get( 0 ); | |
| 356 | final var detail = (NavigationView) master.getDetailNode(); | |
| 357 | final var pane = (DialogPane) view.getParent(); | |
| 358 | ||
| 359 | detail.setOnKeyReleased( ( key ) -> { | |
| 360 | switch( key.getCode() ) { | |
| 361 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 362 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 363 | } | |
| 364 | } ); | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * Creates a label for the given key after interpolating its value. | |
| 369 | * | |
| 370 | * @param key The key to find in the resource bundle. | |
| 371 | * @return The value of the key as a label. | |
| 372 | */ | |
| 373 | private Node label( final Key key ) { | |
| 374 | return label( key, (String[]) null ); | |
| 375 | } | |
| 376 | ||
| 377 | private Node label( final Key key, final String... values ) { | |
| 378 | return new Label( get( key.toString() + ".desc", (Object[]) values ) ); | |
| 379 | } | |
| 380 | ||
| 381 | private String title( final Key key ) { | |
| 382 | return get( key.toString() + ".title" ); | |
| 383 | } | |
| 384 | ||
| 385 | private ObjectProperty<File> fileProperty( final Key key ) { | |
| 386 | return mWorkspace.fileProperty( key ); | |
| 387 | } | |
| 388 | ||
| 389 | private StringProperty stringProperty( final Key key ) { | |
| 390 | return mWorkspace.stringProperty( key ); | |
| 391 | } | |
| 392 | ||
| 393 | private BooleanProperty booleanProperty( final Key key ) { | |
| 394 | return mWorkspace.booleanProperty( key ); | |
| 395 | } | |
| 396 | ||
| 397 | @SuppressWarnings( "SameParameterValue" ) | |
| 398 | private IntegerProperty integerProperty( final Key key ) { | |
| 399 | return mWorkspace.integerProperty( key ); | |
| 400 | } | |
| 401 | ||
| 402 | @SuppressWarnings( "SameParameterValue" ) | |
| 403 | private DoubleProperty doubleProperty( final Key key ) { | |
| 404 | return mWorkspace.doubleProperty( key ); | |
| 405 | } | |
| 406 | ||
| 407 | private ObjectProperty<String> skinProperty( final Key key ) { | |
| 408 | return mWorkspace.skinProperty( key ); | |
| 409 | } | |
| 410 | ||
| 411 | private ObjectProperty<String> localeProperty( final Key key ) { | |
| 412 | return mWorkspace.localeProperty( key ); | |
| 413 | } | |
| 414 | ||
| 415 | private PreferencesFx getPreferencesFx() { | |
| 416 | return mPreferencesFx; | |
| 6 | import com.dlsc.preferencesfx.model.Category; | |
| 7 | import com.dlsc.preferencesfx.model.Group; | |
| 8 | import com.dlsc.preferencesfx.model.Setting; | |
| 9 | import com.dlsc.preferencesfx.util.StorageHandler; | |
| 10 | import com.dlsc.preferencesfx.view.NavigationView; | |
| 11 | import javafx.beans.property.*; | |
| 12 | import javafx.scene.Node; | |
| 13 | import javafx.scene.control.Button; | |
| 14 | import javafx.scene.control.DialogPane; | |
| 15 | import javafx.scene.control.Label; | |
| 16 | import org.controlsfx.control.MasterDetailPane; | |
| 17 | ||
| 18 | import java.io.File; | |
| 19 | import java.util.Map.Entry; | |
| 20 | ||
| 21 | import static com.dlsc.formsfx.model.structure.Field.ofStringType; | |
| 22 | import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED; | |
| 23 | import static com.keenwrite.Messages.get; | |
| 24 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; | |
| 25 | import static com.keenwrite.preferences.AppKeys.*; | |
| 26 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; | |
| 27 | import static com.keenwrite.preferences.SkinProperty.skinListProperty; | |
| 28 | import static com.keenwrite.preferences.TableField.ofListType; | |
| 29 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 30 | import static javafx.scene.control.ButtonType.OK; | |
| 31 | ||
| 32 | /** | |
| 33 | * Provides the ability for users to configure their preferences. This links | |
| 34 | * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC. | |
| 35 | */ | |
| 36 | @SuppressWarnings( "SameParameterValue" ) | |
| 37 | public final class PreferencesController { | |
| 38 | ||
| 39 | private final Workspace mWorkspace; | |
| 40 | private final PreferencesFx mPreferencesFx; | |
| 41 | ||
| 42 | public PreferencesController( final Workspace workspace ) { | |
| 43 | mWorkspace = workspace; | |
| 44 | ||
| 45 | // Order matters: set the workspace before creating the dialog. | |
| 46 | mPreferencesFx = createPreferencesFx(); | |
| 47 | ||
| 48 | initKeyEventHandler( mPreferencesFx ); | |
| 49 | initSaveEventHandler( mPreferencesFx ); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Display the user preferences settings dialog (non-modal). | |
| 54 | */ | |
| 55 | public void show() { | |
| 56 | mPreferencesFx.show( false ); | |
| 57 | } | |
| 58 | ||
| 59 | private StringField createFontNameField( | |
| 60 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 61 | final var control = new SimpleFontControl( "Change" ); | |
| 62 | ||
| 63 | control.fontSizeProperty().addListener( ( c, o, n ) -> { | |
| 64 | if( n != null ) { | |
| 65 | fontSize.set( n.doubleValue() ); | |
| 66 | } | |
| 67 | } ); | |
| 68 | ||
| 69 | return ofStringType( fontName ).render( control ); | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Convenience method to create a helper class for the user interface. This | |
| 74 | * establishes a key-value pair for the view. | |
| 75 | * | |
| 76 | * @param persist A reference to the values that will be persisted. | |
| 77 | * @param <K> The type of key, usually a string. | |
| 78 | * @param <V> The type of value, usually a string. | |
| 79 | * @return UI data model container that may update the persistent state. | |
| 80 | */ | |
| 81 | private <K, V> TableField<Entry<K, V>> createTableField( | |
| 82 | final ListProperty<Entry<K, V>> persist ) { | |
| 83 | return ofListType( persist ).render( new SimpleTableControl<>() ); | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Creates the preferences dialog based using | |
| 88 | * {@link SkeletonStorageHandler} and | |
| 89 | * numerous {@link Category} objects. | |
| 90 | * | |
| 91 | * @return A component for editing preferences. | |
| 92 | * @throws RuntimeException Could not construct the {@link PreferencesFx} | |
| 93 | * object (e.g., illegal access permissions, | |
| 94 | * unmapped XML resource). | |
| 95 | */ | |
| 96 | private PreferencesFx createPreferencesFx() { | |
| 97 | return PreferencesFx.of( createStorageHandler(), createCategories() ) | |
| 98 | .instantPersistent( false ) | |
| 99 | .dialogIcon( ICON_DIALOG ); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Override the {@link PreferencesFx} storage handler to perform no actions. | |
| 104 | * Persistence is accomplished using the {@link XmlStore}. | |
| 105 | * | |
| 106 | * @return A no-op {@link StorageHandler} implementation. | |
| 107 | */ | |
| 108 | private StorageHandler createStorageHandler() { | |
| 109 | return new SkeletonStorageHandler(); | |
| 110 | } | |
| 111 | ||
| 112 | private Category[] createCategories() { | |
| 113 | return new Category[]{ | |
| 114 | Category.of( | |
| 115 | get( KEY_DOC ), | |
| 116 | Group.of( | |
| 117 | get( KEY_DOC_META ), | |
| 118 | Setting.of( label( KEY_DOC_META ) ), | |
| 119 | Setting.of( title( KEY_DOC_META ), | |
| 120 | createTableField( listEntryProperty( KEY_DOC_META ) ), | |
| 121 | listEntryProperty( KEY_DOC_META ) ) | |
| 122 | ) | |
| 123 | ), | |
| 124 | Category.of( | |
| 125 | get( KEY_TYPESET ), | |
| 126 | Group.of( | |
| 127 | get( KEY_TYPESET_CONTEXT ), | |
| 128 | Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | |
| 129 | Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 130 | fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ), | |
| 131 | Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | |
| 132 | Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | |
| 133 | booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 134 | ), | |
| 135 | Group.of( | |
| 136 | get( KEY_TYPESET_TYPOGRAPHY ), | |
| 137 | Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ), | |
| 138 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | |
| 139 | booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 140 | ) | |
| 141 | ), | |
| 142 | Category.of( | |
| 143 | get( KEY_EDITOR ), | |
| 144 | Group.of( | |
| 145 | get( KEY_EDITOR_AUTOSAVE ), | |
| 146 | Setting.of( label( KEY_EDITOR_AUTOSAVE ) ), | |
| 147 | Setting.of( title( KEY_EDITOR_AUTOSAVE ), | |
| 148 | integerProperty( KEY_EDITOR_AUTOSAVE ) ) | |
| 149 | ) | |
| 150 | ), | |
| 151 | Category.of( | |
| 152 | get( KEY_R ), | |
| 153 | Group.of( | |
| 154 | get( KEY_R_DIR ), | |
| 155 | Setting.of( label( KEY_R_DIR ) ), | |
| 156 | Setting.of( title( KEY_R_DIR ), | |
| 157 | fileProperty( KEY_R_DIR ), true ) | |
| 158 | ), | |
| 159 | Group.of( | |
| 160 | get( KEY_R_SCRIPT ), | |
| 161 | Setting.of( label( KEY_R_SCRIPT ) ), | |
| 162 | createMultilineSetting( "Script", KEY_R_SCRIPT ) | |
| 163 | ), | |
| 164 | Group.of( | |
| 165 | get( KEY_R_DELIM_BEGAN ), | |
| 166 | Setting.of( label( KEY_R_DELIM_BEGAN ) ), | |
| 167 | Setting.of( title( KEY_R_DELIM_BEGAN ), | |
| 168 | stringProperty( KEY_R_DELIM_BEGAN ) ) | |
| 169 | ), | |
| 170 | Group.of( | |
| 171 | get( KEY_R_DELIM_ENDED ), | |
| 172 | Setting.of( label( KEY_R_DELIM_ENDED ) ), | |
| 173 | Setting.of( title( KEY_R_DELIM_ENDED ), | |
| 174 | stringProperty( KEY_R_DELIM_ENDED ) ) | |
| 175 | ) | |
| 176 | ), | |
| 177 | Category.of( | |
| 178 | get( KEY_IMAGES ), | |
| 179 | Group.of( | |
| 180 | get( KEY_IMAGES_DIR ), | |
| 181 | Setting.of( label( KEY_IMAGES_DIR ) ), | |
| 182 | Setting.of( title( KEY_IMAGES_DIR ), | |
| 183 | fileProperty( KEY_IMAGES_DIR ), true ) | |
| 184 | ), | |
| 185 | Group.of( | |
| 186 | get( KEY_IMAGES_ORDER ), | |
| 187 | Setting.of( label( KEY_IMAGES_ORDER ) ), | |
| 188 | Setting.of( title( KEY_IMAGES_ORDER ), | |
| 189 | stringProperty( KEY_IMAGES_ORDER ) ) | |
| 190 | ), | |
| 191 | Group.of( | |
| 192 | get( KEY_IMAGES_RESIZE ), | |
| 193 | Setting.of( label( KEY_IMAGES_RESIZE ) ), | |
| 194 | Setting.of( title( KEY_IMAGES_RESIZE ), | |
| 195 | booleanProperty( KEY_IMAGES_RESIZE ) ) | |
| 196 | ), | |
| 197 | Group.of( | |
| 198 | get( KEY_IMAGES_SERVER ), | |
| 199 | Setting.of( label( KEY_IMAGES_SERVER ) ), | |
| 200 | Setting.of( title( KEY_IMAGES_SERVER ), | |
| 201 | stringProperty( KEY_IMAGES_SERVER ) ) | |
| 202 | ) | |
| 203 | ), | |
| 204 | Category.of( | |
| 205 | get( KEY_DEF ), | |
| 206 | Group.of( | |
| 207 | get( KEY_DEF_PATH ), | |
| 208 | Setting.of( label( KEY_DEF_PATH ) ), | |
| 209 | Setting.of( title( KEY_DEF_PATH ), | |
| 210 | fileProperty( KEY_DEF_PATH ), false ) | |
| 211 | ), | |
| 212 | Group.of( | |
| 213 | get( KEY_DEF_DELIM_BEGAN ), | |
| 214 | Setting.of( label( KEY_DEF_DELIM_BEGAN ) ), | |
| 215 | Setting.of( title( KEY_DEF_DELIM_BEGAN ), | |
| 216 | stringProperty( KEY_DEF_DELIM_BEGAN ) ) | |
| 217 | ), | |
| 218 | Group.of( | |
| 219 | get( KEY_DEF_DELIM_ENDED ), | |
| 220 | Setting.of( label( KEY_DEF_DELIM_ENDED ) ), | |
| 221 | Setting.of( title( KEY_DEF_DELIM_ENDED ), | |
| 222 | stringProperty( KEY_DEF_DELIM_ENDED ) ) | |
| 223 | ) | |
| 224 | ), | |
| 225 | Category.of( | |
| 226 | get( KEY_UI_FONT ), | |
| 227 | Group.of( | |
| 228 | get( KEY_UI_FONT_EDITOR ), | |
| 229 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 230 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 231 | createFontNameField( | |
| 232 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 233 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 234 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 235 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 236 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), | |
| 237 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) | |
| 238 | ), | |
| 239 | Group.of( | |
| 240 | get( KEY_UI_FONT_PREVIEW ), | |
| 241 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 242 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 243 | createFontNameField( | |
| 244 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 245 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 246 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 247 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 248 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 249 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 250 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 251 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 252 | createFontNameField( | |
| 253 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 254 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 255 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 256 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 257 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 258 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 259 | ) | |
| 260 | ), | |
| 261 | Category.of( | |
| 262 | get( KEY_UI_SKIN ), | |
| 263 | Group.of( | |
| 264 | get( KEY_UI_SKIN_SELECTION ), | |
| 265 | Setting.of( label( KEY_UI_SKIN_SELECTION ) ), | |
| 266 | Setting.of( title( KEY_UI_SKIN_SELECTION ), | |
| 267 | skinListProperty(), | |
| 268 | skinProperty( KEY_UI_SKIN_SELECTION ) ) | |
| 269 | ), | |
| 270 | Group.of( | |
| 271 | get( KEY_UI_SKIN_CUSTOM ), | |
| 272 | Setting.of( label( KEY_UI_SKIN_CUSTOM ) ), | |
| 273 | Setting.of( title( KEY_UI_SKIN_CUSTOM ), | |
| 274 | fileProperty( KEY_UI_SKIN_CUSTOM ), false ) | |
| 275 | ) | |
| 276 | ), | |
| 277 | Category.of( | |
| 278 | get( KEY_UI_PREVIEW ), | |
| 279 | Group.of( | |
| 280 | get( KEY_UI_PREVIEW_STYLESHEET ), | |
| 281 | Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ), | |
| 282 | Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ), | |
| 283 | fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false ) | |
| 284 | ) | |
| 285 | ), | |
| 286 | Category.of( | |
| 287 | get( KEY_LANGUAGE ), | |
| 288 | Group.of( | |
| 289 | get( KEY_LANGUAGE_LOCALE ), | |
| 290 | Setting.of( label( KEY_LANGUAGE_LOCALE ) ), | |
| 291 | Setting.of( title( KEY_LANGUAGE_LOCALE ), | |
| 292 | localeListProperty(), | |
| 293 | localeProperty( KEY_LANGUAGE_LOCALE ) ) | |
| 294 | ) | |
| 295 | ) | |
| 296 | }; | |
| 297 | } | |
| 298 | ||
| 299 | @SuppressWarnings( "unchecked" ) | |
| 300 | private Setting<StringField, StringProperty> createMultilineSetting( | |
| 301 | final String description, final Key property ) { | |
| 302 | final Setting<StringField, StringProperty> setting = | |
| 303 | Setting.of( description, stringProperty( property ) ); | |
| 304 | final var field = setting.getElement(); | |
| 305 | field.multiline( true ); | |
| 306 | ||
| 307 | return setting; | |
| 308 | } | |
| 309 | ||
| 310 | /** | |
| 311 | * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively. | |
| 312 | */ | |
| 313 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 314 | final var view = preferences.getView(); | |
| 315 | final var nodes = view.getChildrenUnmodifiable(); | |
| 316 | final var master = (MasterDetailPane) nodes.get( 0 ); | |
| 317 | final var detail = (NavigationView) master.getDetailNode(); | |
| 318 | final var pane = (DialogPane) view.getParent(); | |
| 319 | ||
| 320 | detail.setOnKeyReleased( key -> { | |
| 321 | switch( key.getCode() ) { | |
| 322 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 323 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 324 | } | |
| 325 | } ); | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * Called when the user clicks the APPLY or OK buttons in the dialog. | |
| 330 | * | |
| 331 | * @param preferences Preferences widget. | |
| 332 | */ | |
| 333 | private void initSaveEventHandler( final PreferencesFx preferences ) { | |
| 334 | preferences.addEventHandler( | |
| 335 | EVENT_PREFERENCES_SAVED, event -> mWorkspace.save() | |
| 336 | ); | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * Creates a label for the given key after interpolating its value. | |
| 341 | * | |
| 342 | * @param key The key to find in the resource bundle. | |
| 343 | * @return The value of the key as a label. | |
| 344 | */ | |
| 345 | private Node label( final Key key ) { | |
| 346 | return label( key, (String[]) null ); | |
| 347 | } | |
| 348 | ||
| 349 | private Node label( final Key key, final String... values ) { | |
| 350 | return new Label( get( key.toString() + ".desc", (Object[]) values ) ); | |
| 351 | } | |
| 352 | ||
| 353 | private String title( final Key key ) { | |
| 354 | return get( key.toString() + ".title" ); | |
| 355 | } | |
| 356 | ||
| 357 | private ObjectProperty<File> fileProperty( final Key key ) { | |
| 358 | return mWorkspace.fileProperty( key ); | |
| 359 | } | |
| 360 | ||
| 361 | private StringProperty stringProperty( final Key key ) { | |
| 362 | return mWorkspace.stringProperty( key ); | |
| 363 | } | |
| 364 | ||
| 365 | private BooleanProperty booleanProperty( final Key key ) { | |
| 366 | return mWorkspace.booleanProperty( key ); | |
| 367 | } | |
| 368 | ||
| 369 | @SuppressWarnings( "SameParameterValue" ) | |
| 370 | private IntegerProperty integerProperty( final Key key ) { | |
| 371 | return mWorkspace.integerProperty( key ); | |
| 372 | } | |
| 373 | ||
| 374 | @SuppressWarnings( "SameParameterValue" ) | |
| 375 | private DoubleProperty doubleProperty( final Key key ) { | |
| 376 | return mWorkspace.doubleProperty( key ); | |
| 377 | } | |
| 378 | ||
| 379 | private ObjectProperty<String> skinProperty( final Key key ) { | |
| 380 | return mWorkspace.skinProperty( key ); | |
| 381 | } | |
| 382 | ||
| 383 | private ObjectProperty<String> localeProperty( final Key key ) { | |
| 384 | return mWorkspace.localeProperty( key ); | |
| 385 | } | |
| 386 | ||
| 387 | private <K, V> ListProperty<Entry<K, V>> listEntryProperty( final Key key ) { | |
| 388 | return mWorkspace.listsProperty( key ); | |
| 417 | 389 | } |
| 418 | 390 | } |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl; | |
| 5 | import com.keenwrite.ui.table.AltTableCell; | |
| 6 | import javafx.beans.property.SimpleObjectProperty; | |
| 7 | import javafx.event.ActionEvent; | |
| 8 | import javafx.event.EventHandler; | |
| 9 | import javafx.geometry.Insets; | |
| 10 | import javafx.scene.control.Button; | |
| 11 | import javafx.scene.control.ButtonBar; | |
| 12 | import javafx.scene.control.TableColumn; | |
| 13 | import javafx.scene.control.TableColumn.CellEditEvent; | |
| 14 | import javafx.scene.control.TableView; | |
| 15 | import javafx.scene.layout.VBox; | |
| 16 | import javafx.util.StringConverter; | |
| 17 | ||
| 18 | import java.util.AbstractMap.SimpleEntry; | |
| 19 | import java.util.ArrayList; | |
| 20 | import java.util.Map.Entry; | |
| 21 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 22 | import java.util.function.BiFunction; | |
| 23 | import java.util.function.Function; | |
| 24 | ||
| 25 | import static com.keenwrite.ui.fonts.IconFactory.createGraphic; | |
| 26 | import static java.util.Arrays.asList; | |
| 27 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 28 | import static javafx.scene.control.TableView.CONSTRAINED_RESIZE_POLICY; | |
| 29 | ||
| 30 | public class SimpleTableControl<K, V, F extends TableField<Entry<K, V>>> | |
| 31 | extends SimpleControl<F, VBox> { | |
| 32 | ||
| 33 | private static long sCounter; | |
| 34 | ||
| 35 | public SimpleTableControl() {} | |
| 36 | ||
| 37 | @Override | |
| 38 | public void initializeParts() { | |
| 39 | super.initializeParts(); | |
| 40 | ||
| 41 | final var model = field.viewProperty(); | |
| 42 | final var table = new TableView<>( model ); | |
| 43 | ||
| 44 | table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY ); | |
| 45 | table.setEditable( true ); | |
| 46 | table.getColumns().addAll( | |
| 47 | asList( | |
| 48 | createEditableColumnKey( table ), | |
| 49 | createEditableColumnValue( table ) | |
| 50 | ) | |
| 51 | ); | |
| 52 | table.getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 53 | ||
| 54 | final var inserted = workaround( table ); | |
| 55 | ||
| 56 | final var buttons = new ButtonBar(); | |
| 57 | buttons.getButtons().addAll( | |
| 58 | createButton( | |
| 59 | "Add", "PLUS", | |
| 60 | event -> { | |
| 61 | sCounter++; | |
| 62 | ||
| 63 | inserted.set( true ); | |
| 64 | model.add( createEntry( "key" + sCounter, "value" + sCounter ) ); | |
| 65 | } | |
| 66 | ), | |
| 67 | ||
| 68 | createButton( | |
| 69 | "Delete", "TRASH", | |
| 70 | event -> { | |
| 71 | final var selectionModel = table.getSelectionModel(); | |
| 72 | final var selection = selectionModel.getSelectedItems(); | |
| 73 | ||
| 74 | if( selection != null && !selection.isEmpty() ) { | |
| 75 | final var items = table.getItems(); | |
| 76 | final var rows = new ArrayList<>( selection ); | |
| 77 | rows.forEach( items::remove ); | |
| 78 | ||
| 79 | selectionModel.clearSelection(); | |
| 80 | } | |
| 81 | } | |
| 82 | ) | |
| 83 | ); | |
| 84 | ||
| 85 | final var vbox = new VBox(); | |
| 86 | vbox.setSpacing( 5 ); | |
| 87 | vbox.setPadding( new Insets( 10, 0, 0, 10 ) ); | |
| 88 | vbox.getChildren().addAll( table, buttons ); | |
| 89 | ||
| 90 | super.node = vbox; | |
| 91 | } | |
| 92 | ||
| 93 | @SuppressWarnings( "unchecked" ) | |
| 94 | private Entry<K, V> createEntry( final String k, final String v ) { | |
| 95 | return new SimpleEntry<>( (K) k, (V) v ); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * TODO: Delete method when bug is fixed. See the | |
| 100 | * <a href="https://github.com/dlsc-software-consulting-gmbh/PreferencesFX/issues/413">issue | |
| 101 | * tracker</a> for details about the bug. | |
| 102 | * | |
| 103 | * @param table Add a width listener to correct a slight width change. | |
| 104 | * @return A Boolean lock so that the bug fix and "Add" button can | |
| 105 | * be used to ensure regular resizes don't interfere with programmatic ones. | |
| 106 | */ | |
| 107 | private AtomicBoolean workaround( | |
| 108 | final TableView<Entry<K, V>> table ) { | |
| 109 | final var inserted = new AtomicBoolean( true ); | |
| 110 | ||
| 111 | table.widthProperty().addListener( ( c, o, n ) -> { | |
| 112 | if( (o != null && n != null) | |
| 113 | && o.intValue() == n.intValue() - 2 | |
| 114 | && inserted.getAndSet( false ) ) { | |
| 115 | table.setPrefWidth( table.getPrefWidth() - 2 ); | |
| 116 | } | |
| 117 | } ); | |
| 118 | ||
| 119 | return inserted; | |
| 120 | } | |
| 121 | ||
| 122 | private Button createButton( | |
| 123 | final String label, | |
| 124 | final String graphic, | |
| 125 | final EventHandler<ActionEvent> handler ) { | |
| 126 | assert label != null; | |
| 127 | assert !label.isBlank(); | |
| 128 | assert graphic != null; | |
| 129 | assert !graphic.isBlank(); | |
| 130 | assert handler != null; | |
| 131 | ||
| 132 | final var button = new Button( label, createGraphic( graphic ) ); | |
| 133 | button.setOnAction( handler ); | |
| 134 | return button; | |
| 135 | } | |
| 136 | ||
| 137 | private TableColumn<Entry<K, V>, K> createEditableColumnKey( | |
| 138 | final TableView<Entry<K, V>> table ) { | |
| 139 | return createColumn( | |
| 140 | table, | |
| 141 | Entry::getKey, | |
| 142 | ( e, o ) -> new SimpleEntry<>( e.getNewValue(), o.getValue() ), | |
| 143 | "Key", | |
| 144 | .2 | |
| 145 | ); | |
| 146 | } | |
| 147 | ||
| 148 | private TableColumn<Entry<K, V>, V> createEditableColumnValue( | |
| 149 | final TableView<Entry<K, V>> table ) { | |
| 150 | return createColumn( | |
| 151 | table, | |
| 152 | Entry::getValue, | |
| 153 | ( e, o ) -> new SimpleEntry<>( o.getKey(), e.getNewValue() ), | |
| 154 | "Value", | |
| 155 | .8 | |
| 156 | ); | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Creates a table column having cells that be edited. | |
| 161 | * | |
| 162 | * @param table The table to which the column belongs. | |
| 163 | * @param mapEntry Data model backing the edited text. | |
| 164 | * @param label Column name. | |
| 165 | * @param width Fraction of table width (1 = 100%). | |
| 166 | * @param <T> The return type for the column (i.e., key or value). | |
| 167 | * @return The newly configured column. | |
| 168 | */ | |
| 169 | private <T> TableColumn<Entry<K, V>, T> createColumn( | |
| 170 | final TableView<Entry<K, V>> table, | |
| 171 | final Function<Entry<K, V>, T> mapEntry, | |
| 172 | final BiFunction<CellEditEvent<Entry<K, V>, T>, Entry<K, V>, Entry<K, V>> creator, | |
| 173 | final String label, | |
| 174 | final double width | |
| 175 | ) { | |
| 176 | final var column = new TableColumn<Entry<K, V>, T>( label ); | |
| 177 | ||
| 178 | column.setEditable( true ); | |
| 179 | column.setResizable( true ); | |
| 180 | column.prefWidthProperty().bind( table.widthProperty().multiply( width ) ); | |
| 181 | ||
| 182 | column.setOnEditCommit( event -> { | |
| 183 | final var index = event.getTablePosition().getRow(); | |
| 184 | final var view = event.getTableView(); | |
| 185 | final var old = view.getItems().get( index ); | |
| 186 | ||
| 187 | // Update the data model with the new column value. | |
| 188 | view.getItems().set( index, creator.apply( event, old ) ); | |
| 189 | } ); | |
| 190 | ||
| 191 | column.setCellValueFactory( | |
| 192 | cellData -> | |
| 193 | new SimpleObjectProperty<>( mapEntry.apply( cellData.getValue() ) ) | |
| 194 | ); | |
| 195 | ||
| 196 | column.setCellFactory( | |
| 197 | tableColumn -> new AltTableCell<>( | |
| 198 | new StringConverter<>() { | |
| 199 | @Override | |
| 200 | public String toString( final T object ) { | |
| 201 | return object.toString(); | |
| 202 | } | |
| 203 | ||
| 204 | @Override | |
| 205 | @SuppressWarnings( "unchecked" ) | |
| 206 | public T fromString( final String string ) { | |
| 207 | return (T) string; | |
| 208 | } | |
| 209 | } | |
| 210 | ) | |
| 211 | ); | |
| 212 | ||
| 213 | return column; | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Calling {@link #initializeParts()} also performs layout because no handles | |
| 218 | * are kept to the widgets after initialization. | |
| 219 | */ | |
| 220 | @Override | |
| 221 | public void layoutParts() {} | |
| 222 | } | |
| 1 | 223 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.dlsc.preferencesfx.PreferencesFx; | |
| 5 | import com.dlsc.preferencesfx.util.StorageHandler; | |
| 6 | import javafx.collections.ObservableList; | |
| 7 | ||
| 8 | import java.util.prefs.Preferences; | |
| 9 | ||
| 10 | /** | |
| 11 | * Prevents {@link PreferencesFx} from saving. Saving and loading preferences | |
| 12 | * and application window state is accomplished by the {@link Workspace}. This | |
| 13 | * is required to change the user preferences file location and data format. | |
| 14 | * | |
| 15 | * @see XmlStore | |
| 16 | * @see Workspace | |
| 17 | */ | |
| 18 | public final class SkeletonStorageHandler implements StorageHandler { | |
| 19 | @Override | |
| 20 | public void saveSelectedCategory( final String breadcrumb ) {} | |
| 21 | ||
| 22 | @Override | |
| 23 | public String loadSelectedCategory() { | |
| 24 | return ""; | |
| 25 | } | |
| 26 | ||
| 27 | @Override | |
| 28 | public void saveDividerPosition( final double dividerPosition ) {} | |
| 29 | ||
| 30 | @Override | |
| 31 | public double loadDividerPosition() { | |
| 32 | return 0; | |
| 33 | } | |
| 34 | ||
| 35 | @Override | |
| 36 | public void saveWindowWidth( final double windowWidth ) {} | |
| 37 | ||
| 38 | @Override | |
| 39 | public double loadWindowWidth() { | |
| 40 | return 0; | |
| 41 | } | |
| 42 | ||
| 43 | @Override | |
| 44 | public void saveWindowHeight( final double windowHeight ) {} | |
| 45 | ||
| 46 | @Override | |
| 47 | public double loadWindowHeight() { | |
| 48 | return 0; | |
| 49 | } | |
| 50 | ||
| 51 | @Override | |
| 52 | public void saveWindowPosX( final double windowPosX ) {} | |
| 53 | ||
| 54 | @Override | |
| 55 | public double loadWindowPosX() { | |
| 56 | return 0; | |
| 57 | } | |
| 58 | ||
| 59 | @Override | |
| 60 | public void saveWindowPosY( final double windowPosY ) {} | |
| 61 | ||
| 62 | @Override | |
| 63 | public double loadWindowPosY() { | |
| 64 | return 0; | |
| 65 | } | |
| 66 | ||
| 67 | @Override | |
| 68 | public void saveObject( final String breadcrumb, final Object object ) {} | |
| 69 | ||
| 70 | @Override | |
| 71 | public Object loadObject( | |
| 72 | final String breadcrumb, final Object defaultObject ) { | |
| 73 | return defaultObject; | |
| 74 | } | |
| 75 | ||
| 76 | @Override | |
| 77 | public <T> T loadObject( | |
| 78 | final String breadcrumb, final Class<T> type, final T defaultObject ) { | |
| 79 | return defaultObject; | |
| 80 | } | |
| 81 | ||
| 82 | @Override | |
| 83 | @SuppressWarnings( "rawtypes" ) | |
| 84 | public ObservableList loadObservableList( | |
| 85 | final String breadcrumb, final ObservableList defaultObservableList ) { | |
| 86 | return defaultObservableList; | |
| 87 | } | |
| 88 | ||
| 89 | @Override | |
| 90 | public <T> ObservableList<T> loadObservableList( | |
| 91 | final String breadcrumb, | |
| 92 | final Class<T> type, | |
| 93 | final ObservableList<T> defaultObservableList ) { | |
| 94 | return defaultObservableList; | |
| 95 | } | |
| 96 | ||
| 97 | @Override | |
| 98 | public boolean clearPreferences() { | |
| 99 | return false; | |
| 100 | } | |
| 101 | ||
| 102 | @Override | |
| 103 | public Preferences getPreferences() { | |
| 104 | return null; | |
| 105 | } | |
| 106 | } | |
| 1 | 107 |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | public SkinProperty( final String skin ) { | |
| 34 | super( skin ); | |
| 35 | } | |
| 36 | ||
| 33 | /** | |
| 34 | * Returns the list of available skin names to change the UI fonts and | |
| 35 | * colours. | |
| 36 | * | |
| 37 | * @return A selection of skins. | |
| 38 | */ | |
| 37 | 39 | public static ObservableList<String> skinListProperty() { |
| 40 | assert !sSkins.isEmpty(); | |
| 41 | ||
| 38 | 42 | return listProperty( sSkins ); |
| 39 | 43 | } |
| ... | ||
| 51 | 55 | */ |
| 52 | 56 | public static String toFilename( final String skin ) { |
| 57 | assert skin != null; | |
| 58 | ||
| 53 | 59 | return sanitize( skin ).toLowerCase().replace( ' ', '_' ); |
| 54 | 60 | } |
| ... | ||
| 61 | 67 | */ |
| 62 | 68 | private static String sanitize( final String skin ) { |
| 69 | assert skin != null; | |
| 70 | ||
| 63 | 71 | return sSkins.contains( skin ) ? skin : SKIN_DEFAULT; |
| 72 | } | |
| 73 | ||
| 74 | public SkinProperty( final String skin ) { | |
| 75 | super( skin ); | |
| 64 | 76 | } |
| 65 | 77 | } |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.dlsc.formsfx.model.structure.Field; | |
| 5 | import com.dlsc.formsfx.model.util.BindingMode; | |
| 6 | import javafx.beans.property.ListProperty; | |
| 7 | import javafx.beans.property.Property; | |
| 8 | import javafx.beans.property.SimpleListProperty; | |
| 9 | ||
| 10 | import java.util.ArrayList; | |
| 11 | ||
| 12 | import static com.dlsc.formsfx.model.util.BindingMode.CONTINUOUS; | |
| 13 | import static javafx.collections.FXCollections.observableList; | |
| 14 | ||
| 15 | /** | |
| 16 | * Responsible for binding a form field to a map of values that, ultimately, | |
| 17 | * users may edit. | |
| 18 | * | |
| 19 | * @param <P> The type of {@link Property} to store in the list. | |
| 20 | */ | |
| 21 | public class TableField<P> extends Field<TableField<P>> { | |
| 22 | ||
| 23 | /** | |
| 24 | * Create a writeable list as the data model. | |
| 25 | */ | |
| 26 | private final ListProperty<P> mViewProperty = new SimpleListProperty<>( | |
| 27 | observableList( new ArrayList<>() ) | |
| 28 | ); | |
| 29 | ||
| 30 | /** | |
| 31 | * Contains the data model entries to persist. | |
| 32 | */ | |
| 33 | private final ListProperty<P> mSaveProperty; | |
| 34 | ||
| 35 | /** | |
| 36 | * Creates a new {@link TableField} with a reference to the list that is to | |
| 37 | * be persisted. | |
| 38 | * | |
| 39 | * @param persist A list of items that will be persisted. | |
| 40 | * @param <P> The type of elements in the list to persist. | |
| 41 | * @return A new {@link TableField} used to help render a UI widget. | |
| 42 | */ | |
| 43 | public static <P> TableField<P> ofListType( final ListProperty<P> persist ) { | |
| 44 | return new TableField<>( persist ); | |
| 45 | } | |
| 46 | ||
| 47 | private TableField( final ListProperty<P> property ) { | |
| 48 | mSaveProperty = property; | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Returns the data model that seeds the user interface. At any point the | |
| 53 | * user may cancel editing, which will revert to the previously persisted | |
| 54 | * set. | |
| 55 | * | |
| 56 | * @return The source for values displayed in the UI. | |
| 57 | */ | |
| 58 | public ListProperty<P> viewProperty() { | |
| 59 | return mViewProperty; | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Called when a new UI instance is opened. | |
| 64 | * | |
| 65 | * @param bindingMode Indicates how the view data model is bound to the | |
| 66 | * persistence data model. | |
| 67 | */ | |
| 68 | @Override | |
| 69 | public void setBindingMode( final BindingMode bindingMode ) { | |
| 70 | if( CONTINUOUS.equals( bindingMode ) ) { | |
| 71 | mViewProperty.addAll( mSaveProperty ); | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Answers whether the user input is valid. | |
| 77 | * | |
| 78 | * @return {@code true} Users may provide any key or value strings. | |
| 79 | */ | |
| 80 | @Override | |
| 81 | protected boolean validate() { | |
| 82 | return true; | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Update the properties to save by copying the properties updated in the | |
| 87 | * user interface (i.e., the view). To be clear, the properties are not | |
| 88 | * persisted after calling this method, merely moved out of the UI data | |
| 89 | * model and into the to-be-saved data model. | |
| 90 | */ | |
| 91 | @Override | |
| 92 | public void persist() { | |
| 93 | mSaveProperty.clear(); | |
| 94 | mSaveProperty.addAll( mViewProperty ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * The {@link TableField} doesn't bind values, as such the reset can be | |
| 99 | * a no-op because only {@link #persist()} will update the properties to | |
| 100 | * save. | |
| 101 | */ | |
| 102 | @Override | |
| 103 | public void reset() {} | |
| 104 | } | |
| 1 | 105 |
| 2 | 2 | package com.keenwrite.preferences; |
| 3 | 3 | |
| 4 | import com.keenwrite.constants.Constants; | |
| 5 | import com.keenwrite.sigils.Sigils; | |
| 6 | import javafx.application.Platform; | |
| 7 | import javafx.beans.property.*; | |
| 8 | import javafx.collections.ObservableList; | |
| 9 | import org.apache.commons.configuration2.XMLConfiguration; | |
| 10 | import org.apache.commons.configuration2.builder.fluent.Configurations; | |
| 11 | import org.apache.commons.configuration2.io.FileHandler; | |
| 12 | ||
| 13 | import java.io.File; | |
| 14 | import java.time.Year; | |
| 15 | import java.time.ZonedDateTime; | |
| 16 | import java.util.*; | |
| 17 | import java.util.function.BiConsumer; | |
| 18 | import java.util.function.BooleanSupplier; | |
| 19 | import java.util.function.Consumer; | |
| 20 | import java.util.function.Function; | |
| 21 | ||
| 22 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 23 | import static com.keenwrite.Launcher.getVersion; | |
| 24 | import static com.keenwrite.constants.Constants.*; | |
| 25 | import static com.keenwrite.events.StatusEvent.clue; | |
| 26 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 27 | import static java.lang.String.valueOf; | |
| 28 | import static java.lang.System.getProperty; | |
| 29 | import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; | |
| 30 | import static java.util.Map.entry; | |
| 31 | import static javafx.application.Platform.runLater; | |
| 32 | import static javafx.collections.FXCollections.observableArrayList; | |
| 33 | import static javafx.collections.FXCollections.observableSet; | |
| 34 | ||
| 35 | /** | |
| 36 | * Responsible for defining behaviours for separate projects. A workspace has | |
| 37 | * the ability to save and restore a session, including the window dimensions, | |
| 38 | * tab setup, files, and user preferences. | |
| 39 | * <p> | |
| 40 | * The configuration must support hierarchical (nested) configuration nodes | |
| 41 | * to persist the user interface state. Although possible with a flat | |
| 42 | * configuration file, it's not nearly as simple or elegant. | |
| 43 | * </p> | |
| 44 | * <p> | |
| 45 | * Neither JSON nor HOCON support schema validation and versioning, which makes | |
| 46 | * XML the more suitable configuration file format. Schema validation and | |
| 47 | * versioning provide future-proofing and ease of reading and upgrading previous | |
| 48 | * versions of the configuration file. | |
| 49 | * </p> | |
| 50 | * <p> | |
| 51 | * Persistent preferences may be set directly by the user or indirectly by | |
| 52 | * the act of using the application. | |
| 53 | * </p> | |
| 54 | * <p> | |
| 55 | * Note the following definitions: | |
| 56 | * </p> | |
| 57 | * <dl> | |
| 58 | * <dt>File</dt> | |
| 59 | * <dd>References a file name (no path), path, or directory.</dd> | |
| 60 | * <dt>Path</dt> | |
| 61 | * <dd>Fully qualified file name, which includes all parent directories.</dd> | |
| 62 | * <dt>Dir</dt> | |
| 63 | * <dd>Directory without file name ({@link File#isDirectory()} is true).</dd> | |
| 64 | * </dl> | |
| 65 | */ | |
| 66 | public final class Workspace { | |
| 67 | private final Map<Key, Property<?>> VALUES = Map.ofEntries( | |
| 68 | entry( KEY_META_VERSION, asStringProperty( getVersion() ) ), | |
| 69 | entry( KEY_META_NAME, asStringProperty( "default" ) ), | |
| 70 | ||
| 71 | entry( KEY_DOC_TITLE, asStringProperty( "title" ) ), | |
| 72 | entry( KEY_DOC_AUTHOR, asStringProperty( getProperty( "user.name" ) ) ), | |
| 73 | entry( KEY_DOC_BYLINE, asStringProperty( getProperty( "user.name" ) ) ), | |
| 74 | entry( KEY_DOC_ADDRESS, asStringProperty( "" ) ), | |
| 75 | entry( KEY_DOC_PHONE, asStringProperty( "" ) ), | |
| 76 | entry( KEY_DOC_EMAIL, asStringProperty( "" ) ), | |
| 77 | entry( KEY_DOC_KEYWORDS, asStringProperty( "science, nature" ) ), | |
| 78 | entry( KEY_DOC_COPYRIGHT, asStringProperty( getYear() ) ), | |
| 79 | entry( KEY_DOC_DATE, asStringProperty( getDate() ) ), | |
| 80 | ||
| 81 | entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ), | |
| 82 | ||
| 83 | entry( KEY_R_SCRIPT, asStringProperty( "" ) ), | |
| 84 | entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 85 | entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ), | |
| 86 | entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ), | |
| 87 | ||
| 88 | entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 89 | entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ), | |
| 90 | entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ), | |
| 91 | entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ), | |
| 92 | ||
| 93 | entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 94 | entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ), | |
| 95 | entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ), | |
| 96 | ||
| 97 | entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 98 | entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ), | |
| 99 | entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 100 | entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ), | |
| 101 | ||
| 102 | //@formatter:off | |
| 103 | entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ), | |
| 104 | entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ), | |
| 105 | entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ), | |
| 106 | entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ), | |
| 107 | entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ), | |
| 108 | entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ), | |
| 109 | ||
| 110 | entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ), | |
| 111 | entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ), | |
| 112 | entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ), | |
| 113 | entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ), | |
| 114 | entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ), | |
| 115 | entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ), | |
| 116 | ||
| 117 | entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ), | |
| 118 | entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ), | |
| 119 | ||
| 120 | entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ), | |
| 121 | ||
| 122 | entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), | |
| 123 | ||
| 124 | entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ), | |
| 125 | entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ), | |
| 126 | entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ), | |
| 127 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ) | |
| 128 | //@formatter:on | |
| 129 | ); | |
| 130 | ||
| 131 | private StringProperty asStringProperty( final String defaultValue ) { | |
| 132 | return new SimpleStringProperty( defaultValue ); | |
| 133 | } | |
| 134 | ||
| 135 | @SuppressWarnings( "SameParameterValue" ) | |
| 136 | private IntegerProperty asIntegerProperty( final int defaultValue ) { | |
| 137 | return new SimpleIntegerProperty( defaultValue ); | |
| 138 | } | |
| 139 | ||
| 140 | private DoubleProperty asDoubleProperty( final double defaultValue ) { | |
| 141 | return new SimpleDoubleProperty( defaultValue ); | |
| 142 | } | |
| 143 | ||
| 144 | private BooleanProperty asBooleanProperty() { | |
| 145 | return new SimpleBooleanProperty(); | |
| 146 | } | |
| 147 | ||
| 148 | @SuppressWarnings( "SameParameterValue" ) | |
| 149 | private BooleanProperty asBooleanProperty( final boolean defaultValue ) { | |
| 150 | return new SimpleBooleanProperty( defaultValue ); | |
| 151 | } | |
| 152 | ||
| 153 | private FileProperty asFileProperty( final File defaultValue ) { | |
| 154 | return new FileProperty( defaultValue ); | |
| 155 | } | |
| 156 | ||
| 157 | @SuppressWarnings( "SameParameterValue" ) | |
| 158 | private SkinProperty asSkinProperty( final String defaultValue ) { | |
| 159 | return new SkinProperty( defaultValue ); | |
| 160 | } | |
| 161 | ||
| 162 | @SuppressWarnings( "SameParameterValue" ) | |
| 163 | private LocaleProperty asLocaleProperty( final Locale defaultValue ) { | |
| 164 | return new LocaleProperty( defaultValue ); | |
| 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 | FileProperty.class, File::new | |
| 178 | ); | |
| 179 | ||
| 180 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 181 | Map.of( | |
| 182 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 183 | ); | |
| 184 | ||
| 185 | private final Map<Key, SetProperty<?>> SETS = Map.ofEntries( | |
| 186 | entry( | |
| 187 | KEY_UI_FILES_PATH, | |
| 188 | new SimpleSetProperty<>( observableSet( new HashSet<>() ) ) | |
| 189 | ) | |
| 190 | ); | |
| 191 | ||
| 192 | /** | |
| 193 | * Creates a new {@link Workspace} that will attempt to load a configuration | |
| 194 | * file. If the configuration file cannot be loaded, the workspace settings | |
| 195 | * will return default values. This allows unit tests to provide an instance | |
| 196 | * of {@link Workspace} when necessary without encountering failures. | |
| 197 | */ | |
| 198 | public Workspace() { | |
| 199 | load( FILE_PREFERENCES ); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Creates a new {@link Workspace} that will attempt to load the given | |
| 204 | * configuration file. | |
| 205 | * | |
| 206 | * @param filename The file to load. | |
| 207 | */ | |
| 208 | public Workspace( final String filename ) { | |
| 209 | load( filename ); | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * Creates an instance of {@link ObservableList} that is based on a | |
| 214 | * modifiable observable array list for the given items. | |
| 215 | * | |
| 216 | * @param items The items to wrap in an observable list. | |
| 217 | * @param <E> The type of items to add to the list. | |
| 218 | * @return An observable property that can have its contents modified. | |
| 219 | */ | |
| 220 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 221 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * Returns a value that represents a setting in the application that the user | |
| 226 | * may configure, either directly or indirectly. | |
| 227 | * | |
| 228 | * @param key The reference to the users' preference stored in deference | |
| 229 | * of app reëntrance. | |
| 230 | * @return An observable property to be persisted. | |
| 231 | */ | |
| 232 | @SuppressWarnings( "unchecked" ) | |
| 233 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 234 | assert key != null; | |
| 235 | // The type that goes into the map must come out. | |
| 236 | return (U) VALUES.get( key ); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Returns a list of values that represent a setting in the application that | |
| 241 | * the user may configure, either directly or indirectly. The property | |
| 242 | * returned is backed by a mutable {@link Set}. | |
| 243 | * | |
| 244 | * @param key The {@link Key} associated with a preference value. | |
| 245 | * @return An observable property to be persisted. | |
| 246 | */ | |
| 247 | @SuppressWarnings( "unchecked" ) | |
| 248 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 249 | assert key != null; | |
| 250 | // The type that goes into the map must come out. | |
| 251 | return (SetProperty<T>) SETS.get( key ); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * Returns the {@link Boolean} preference value associated with the given | |
| 256 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 257 | * associated with a value that matches the return type. | |
| 258 | * | |
| 259 | * @param key The {@link Key} associated with a preference value. | |
| 260 | * @return The value associated with the given {@link Key}. | |
| 261 | */ | |
| 262 | public boolean toBoolean( final Key key ) { | |
| 263 | assert key != null; | |
| 264 | return (Boolean) valuesProperty( key ).getValue(); | |
| 265 | } | |
| 266 | ||
| 267 | /** | |
| 268 | * Returns the {@link Integer} preference value associated with the given | |
| 269 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 270 | * associated with a value that matches the return type. | |
| 271 | * | |
| 272 | * @param key The {@link Key} associated with a preference value. | |
| 273 | * @return The value associated with the given {@link Key}. | |
| 274 | */ | |
| 275 | public int toInteger( final Key key ) { | |
| 276 | assert key != null; | |
| 277 | return (Integer) valuesProperty( key ).getValue(); | |
| 278 | } | |
| 279 | ||
| 280 | /** | |
| 281 | * Returns the {@link Double} preference value associated with the given | |
| 282 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 283 | * associated with a value that matches the return type. | |
| 284 | * | |
| 285 | * @param key The {@link Key} associated with a preference value. | |
| 286 | * @return The value associated with the given {@link Key}. | |
| 287 | */ | |
| 288 | public double toDouble( final Key key ) { | |
| 289 | assert key != null; | |
| 290 | return (Double) valuesProperty( key ).getValue(); | |
| 291 | } | |
| 292 | ||
| 293 | public File toFile( final Key key ) { | |
| 294 | assert key != null; | |
| 295 | return fileProperty( key ).get(); | |
| 296 | } | |
| 297 | ||
| 298 | public String toString( final Key key ) { | |
| 299 | assert key != null; | |
| 300 | return stringProperty( key ).get(); | |
| 301 | } | |
| 302 | ||
| 303 | public Sigils toSigils( final Key began, final Key ended ) { | |
| 304 | assert began != null; | |
| 305 | assert ended != null; | |
| 306 | return new Sigils( stringProperty( began ), stringProperty( ended ) ); | |
| 307 | } | |
| 308 | ||
| 309 | @SuppressWarnings( "SameParameterValue" ) | |
| 310 | public IntegerProperty integerProperty( final Key key ) { | |
| 311 | assert key != null; | |
| 312 | return valuesProperty( key ); | |
| 313 | } | |
| 314 | ||
| 315 | @SuppressWarnings( "SameParameterValue" ) | |
| 316 | public DoubleProperty doubleProperty( final Key key ) { | |
| 317 | assert key != null; | |
| 318 | return valuesProperty( key ); | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * Returns the {@link File} {@link Property} associated with the given | |
| 323 | * {@link Key} from the internal list of preference values. The caller | |
| 324 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 325 | * {@link Property}. | |
| 326 | * | |
| 327 | * @param key The {@link Key} associated with a preference value. | |
| 328 | * @return The value associated with the given {@link Key}. | |
| 329 | */ | |
| 330 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 331 | assert key != null; | |
| 332 | return valuesProperty( key ); | |
| 333 | } | |
| 334 | ||
| 335 | public ObjectProperty<String> skinProperty( final Key key ) { | |
| 336 | assert key != null; | |
| 337 | return valuesProperty( key ); | |
| 338 | } | |
| 339 | ||
| 340 | public LocaleProperty localeProperty( final Key key ) { | |
| 341 | assert key != null; | |
| 342 | return valuesProperty( key ); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Returns the language locale setting for the | |
| 347 | * {@link WorkspaceKeys#KEY_LANGUAGE_LOCALE} key. | |
| 348 | * | |
| 349 | * @return The user's current locale setting. | |
| 350 | */ | |
| 351 | public Locale getLocale() { | |
| 352 | return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | |
| 353 | } | |
| 354 | ||
| 355 | public StringProperty stringProperty( final Key key ) { | |
| 356 | assert key != null; | |
| 357 | return valuesProperty( key ); | |
| 358 | } | |
| 359 | ||
| 360 | public BooleanProperty booleanProperty( final Key key ) { | |
| 361 | assert key != null; | |
| 362 | return valuesProperty( key ); | |
| 363 | } | |
| 364 | ||
| 365 | public void loadValueKeys( final Consumer<Key> consumer ) { | |
| 366 | VALUES.keySet().forEach( consumer ); | |
| 367 | } | |
| 368 | ||
| 369 | public void loadSetKeys( final Consumer<Key> consumer ) { | |
| 370 | SETS.keySet().forEach( consumer ); | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Calls the given consumer for all single-value keys. For lists, see | |
| 375 | * {@link #saveSets(BiConsumer)}. | |
| 376 | * | |
| 377 | * @param consumer Called to accept each preference key value. | |
| 378 | */ | |
| 379 | public void saveValues( final BiConsumer<Key, Property<?>> consumer ) { | |
| 380 | VALUES.forEach( consumer ); | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * Calls the given consumer for all multi-value keys. For single items, see | |
| 385 | * {@link #saveValues(BiConsumer)}. Callers are responsible for iterating | |
| 386 | * over the list of items retrieved through this method. | |
| 387 | * | |
| 388 | * @param consumer Called to accept each preference key list. | |
| 389 | */ | |
| 390 | public void saveSets( final BiConsumer<Key, SetProperty<?>> consumer ) { | |
| 391 | SETS.forEach( consumer ); | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 396 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 397 | * indicate the property changes always take effect. | |
| 398 | * | |
| 399 | * @param key The value to bind to the internal key property. | |
| 400 | * @param property The external property value that sets the internal value. | |
| 401 | */ | |
| 402 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 403 | listen( key, property, () -> true ); | |
| 404 | } | |
| 405 | ||
| 406 | /** | |
| 407 | * Binds a read-only property to a value in the preferences. This allows | |
| 408 | * user interface properties to change and the preferences will be | |
| 409 | * synchronized automatically. | |
| 410 | * <p> | |
| 411 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 412 | * application window states are finished before assessing whether property | |
| 413 | * changes should be applied. Without this, exiting the application while the | |
| 414 | * window is maximized would persist the window's maximum dimensions, | |
| 415 | * preventing restoration to its prior, non-maximum size. | |
| 416 | * </p> | |
| 417 | * | |
| 418 | * @param key The value to bind to the internal key property. | |
| 419 | * @param property The external property value that sets the internal value. | |
| 420 | * @param enabled Indicates whether property changes should be applied. | |
| 421 | */ | |
| 422 | public <T> void listen( | |
| 423 | final Key key, | |
| 424 | final ReadOnlyProperty<T> property, | |
| 425 | final BooleanSupplier enabled ) { | |
| 426 | property.addListener( | |
| 427 | ( c, o, n ) -> runLater( () -> { | |
| 428 | if( enabled.getAsBoolean() ) { | |
| 429 | valuesProperty( key ).setValue( n ); | |
| 430 | } | |
| 431 | } ) | |
| 432 | ); | |
| 433 | } | |
| 434 | ||
| 435 | /** | |
| 436 | * Saves the current workspace. | |
| 437 | */ | |
| 438 | public void save() { | |
| 439 | try { | |
| 440 | final var config = new XMLConfiguration(); | |
| 441 | ||
| 442 | // The root config key can only be set for an empty configuration file. | |
| 443 | config.setRootElementName( APP_TITLE_LOWERCASE ); | |
| 444 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 445 | ||
| 446 | saveValues( ( key, property ) -> | |
| 447 | config.setProperty( key.toString(), marshall( property ) ) | |
| 448 | ); | |
| 449 | ||
| 450 | saveSets( ( key, set ) -> { | |
| 451 | final var keyName = key.toString(); | |
| 452 | set.forEach( ( value ) -> config.addProperty( keyName, value ) ); | |
| 453 | } ); | |
| 454 | new FileHandler( config ).save( FILE_PREFERENCES ); | |
| 455 | } catch( final Exception ex ) { | |
| 456 | clue( ex ); | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | /** | |
| 461 | * Attempts to load the {@link Constants#FILE_PREFERENCES} configuration file. | |
| 462 | * If not found, this will fall back to an empty configuration file, leaving | |
| 463 | * the application to fill in default values. | |
| 464 | * | |
| 465 | * @param filename The file containing user preferences to load. | |
| 466 | */ | |
| 467 | private void load( final String filename ) { | |
| 468 | try { | |
| 469 | final var config = new Configurations().xml( filename ); | |
| 470 | ||
| 471 | loadValueKeys( ( key ) -> { | |
| 472 | final var configValue = config.getProperty( key.toString() ); | |
| 473 | ||
| 474 | // Allow other properties to load, even if any are missing. | |
| 475 | if( configValue != null ) { | |
| 476 | final var propertyValue = valuesProperty( key ); | |
| 477 | propertyValue.setValue( unmarshall( propertyValue, configValue ) ); | |
| 478 | } | |
| 479 | } ); | |
| 480 | ||
| 481 | loadSetKeys( ( key ) -> { | |
| 482 | final var configSet = | |
| 483 | new LinkedHashSet<>( config.getList( key.toString() ) ); | |
| 484 | final var propertySet = setsProperty( key ); | |
| 485 | propertySet.setValue( observableSet( configSet ) ); | |
| 486 | } ); | |
| 487 | } catch( final Exception ex ) { | |
| 488 | clue( ex ); | |
| 489 | } | |
| 490 | } | |
| 491 | ||
| 492 | private Object unmarshall( | |
| 493 | final Property<?> property, final Object configValue ) { | |
| 494 | final var setting = configValue.toString(); | |
| 495 | ||
| 496 | return UNMARSHALL | |
| 497 | .getOrDefault( property.getClass(), ( value ) -> value ) | |
| 498 | .apply( setting ); | |
| 499 | } | |
| 500 | ||
| 501 | private Object marshall( final Property<?> property ) { | |
| 502 | return property.getValue() == null | |
| 503 | ? null | |
| 504 | : MARSHALL | |
| 505 | .getOrDefault( property.getClass(), ( __ ) -> property.getValue() ) | |
| 506 | .apply( property.getValue().toString() ); | |
| 507 | } | |
| 508 | ||
| 509 | private String getYear() { | |
| 510 | return valueOf( Year.now().getValue() ); | |
| 511 | } | |
| 512 | ||
| 513 | private String getDate() { | |
| 514 | return ZonedDateTime.now().format( RFC_1123_DATE_TIME ); | |
| 4 | import com.keenwrite.io.MediaType; | |
| 5 | import com.keenwrite.sigils.PropertyKeyOperator; | |
| 6 | import com.keenwrite.sigils.SigilKeyOperator; | |
| 7 | import javafx.application.Platform; | |
| 8 | import javafx.beans.property.*; | |
| 9 | import javafx.collections.ObservableList; | |
| 10 | ||
| 11 | import java.io.File; | |
| 12 | import java.nio.file.Path; | |
| 13 | import java.util.*; | |
| 14 | import java.util.Map.Entry; | |
| 15 | import java.util.function.BooleanSupplier; | |
| 16 | import java.util.function.Function; | |
| 17 | ||
| 18 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 19 | import static com.keenwrite.Launcher.getVersion; | |
| 20 | import static com.keenwrite.constants.Constants.*; | |
| 21 | import static com.keenwrite.events.StatusEvent.clue; | |
| 22 | import static com.keenwrite.preferences.AppKeys.*; | |
| 23 | import static java.util.Map.entry; | |
| 24 | import static javafx.application.Platform.runLater; | |
| 25 | import static javafx.collections.FXCollections.observableArrayList; | |
| 26 | import static javafx.collections.FXCollections.observableSet; | |
| 27 | ||
| 28 | /** | |
| 29 | * Responsible for defining behaviours for separate projects. A workspace has | |
| 30 | * the ability to save and restore a session, including the window dimensions, | |
| 31 | * tab setup, files, and user preferences. | |
| 32 | * <p> | |
| 33 | * The configuration must support hierarchical (nested) configuration nodes | |
| 34 | * to persist the user interface state. Although possible with a flat | |
| 35 | * configuration file, it's not nearly as simple or elegant. | |
| 36 | * </p> | |
| 37 | * <p> | |
| 38 | * Neither JSON nor HOCON support schema validation and versioning, which makes | |
| 39 | * XML the more suitable configuration file format. Schema validation and | |
| 40 | * versioning provide future-proofing and ease of reading and upgrading previous | |
| 41 | * versions of the configuration file. | |
| 42 | * </p> | |
| 43 | * <p> | |
| 44 | * Persistent preferences may be set directly by the user or indirectly by | |
| 45 | * the act of using the application. | |
| 46 | * </p> | |
| 47 | * <p> | |
| 48 | * Note the following definitions: | |
| 49 | * </p> | |
| 50 | * <dl> | |
| 51 | * <dt>File</dt> | |
| 52 | * <dd>References a file name (no path), path, or directory.</dd> | |
| 53 | * <dt>Path</dt> | |
| 54 | * <dd>Fully qualified file name, which includes all parent directories.</dd> | |
| 55 | * <dt>Dir</dt> | |
| 56 | * <dd>Directory without file name ({@link File#isDirectory()} is true).</dd> | |
| 57 | * </dl> | |
| 58 | */ | |
| 59 | public final class Workspace implements KeyConfiguration { | |
| 60 | /** | |
| 61 | * Main configuration values, single text strings. | |
| 62 | */ | |
| 63 | private final Map<Key, Property<?>> mValues = Map.ofEntries( | |
| 64 | entry( KEY_META_VERSION, asStringProperty( getVersion() ) ), | |
| 65 | entry( KEY_META_NAME, asStringProperty( "default" ) ), | |
| 66 | ||
| 67 | entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ), | |
| 68 | ||
| 69 | entry( KEY_R_SCRIPT, asStringProperty( "" ) ), | |
| 70 | entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 71 | entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ), | |
| 72 | entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ), | |
| 73 | ||
| 74 | entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 75 | entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ), | |
| 76 | entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ), | |
| 77 | entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ), | |
| 78 | ||
| 79 | entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 80 | entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ), | |
| 81 | entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ), | |
| 82 | ||
| 83 | entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ), | |
| 84 | entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ), | |
| 85 | entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ), | |
| 86 | entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ), | |
| 87 | ||
| 88 | //@formatter:off | |
| 89 | entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ), | |
| 90 | entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ), | |
| 91 | entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ), | |
| 92 | entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ), | |
| 93 | entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ), | |
| 94 | entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ), | |
| 95 | ||
| 96 | entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ), | |
| 97 | entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ), | |
| 98 | entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ), | |
| 99 | entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ), | |
| 100 | entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ), | |
| 101 | entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ), | |
| 102 | ||
| 103 | entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ), | |
| 104 | entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ), | |
| 105 | ||
| 106 | entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ), | |
| 107 | ||
| 108 | entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), | |
| 109 | ||
| 110 | entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ), | |
| 111 | entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ), | |
| 112 | entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ), | |
| 113 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ) | |
| 114 | //@formatter:on | |
| 115 | ); | |
| 116 | ||
| 117 | /** | |
| 118 | * Sets of configuration values, all the same type (e.g., file names), | |
| 119 | * where the key name doesn't change per set. | |
| 120 | */ | |
| 121 | private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | |
| 122 | entry( | |
| 123 | KEY_UI_RECENT_OPEN_PATH, | |
| 124 | createSetProperty( new HashSet<String>() ) | |
| 125 | ) | |
| 126 | ); | |
| 127 | ||
| 128 | /** | |
| 129 | * Lists of configuration values, such as key-value pairs where both the | |
| 130 | * key name and the value must be preserved per list. | |
| 131 | */ | |
| 132 | private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | |
| 133 | entry( | |
| 134 | KEY_DOC_META, | |
| 135 | createListProperty( new LinkedList<Entry<String, String>>() ) | |
| 136 | ) | |
| 137 | ); | |
| 138 | ||
| 139 | /** | |
| 140 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 141 | */ | |
| 142 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 143 | Map.of( | |
| 144 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 145 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 146 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 147 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 148 | SimpleFloatProperty.class, Float::parseFloat, | |
| 149 | SimpleStringProperty.class, String::new, | |
| 150 | SimpleObjectProperty.class, String::new, | |
| 151 | SkinProperty.class, String::new, | |
| 152 | FileProperty.class, File::new | |
| 153 | ); | |
| 154 | ||
| 155 | /** | |
| 156 | * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | |
| 157 | * can simply call {@link Object#toString()} to convert the value to a string. | |
| 158 | */ | |
| 159 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 160 | Map.of( | |
| 161 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 162 | ); | |
| 163 | ||
| 164 | /** | |
| 165 | * Converts the given {@link Property} value to a string. | |
| 166 | * | |
| 167 | * @param property The {@link Property} to convert. | |
| 168 | * @return A string representation of the given property, or the empty | |
| 169 | * string if no conversion was possible. | |
| 170 | */ | |
| 171 | private static String marshall( final Property<?> property ) { | |
| 172 | final var v = property.getValue(); | |
| 173 | ||
| 174 | return v == null | |
| 175 | ? "" | |
| 176 | : MARSHALL | |
| 177 | .getOrDefault( property.getClass(), __ -> property.getValue() ) | |
| 178 | .apply( v.toString() ) | |
| 179 | .toString(); | |
| 180 | } | |
| 181 | ||
| 182 | private static Object unmarshall( | |
| 183 | final Property<?> property, final Object configValue ) { | |
| 184 | final var v = configValue.toString(); | |
| 185 | ||
| 186 | return UNMARSHALL | |
| 187 | .getOrDefault( property.getClass(), value -> property.getValue() ) | |
| 188 | .apply( v ); | |
| 189 | } | |
| 190 | ||
| 191 | /** | |
| 192 | * Creates an instance of {@link ObservableList} that is based on a | |
| 193 | * modifiable observable array list for the given items. | |
| 194 | * | |
| 195 | * @param items The items to wrap in an observable list. | |
| 196 | * @param <E> The type of items to add to the list. | |
| 197 | * @return An observable property that can have its contents modified. | |
| 198 | */ | |
| 199 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 200 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 201 | } | |
| 202 | ||
| 203 | private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | |
| 204 | return new SimpleSetProperty<>( observableSet( set ) ); | |
| 205 | } | |
| 206 | ||
| 207 | private static <E> ListProperty<E> createListProperty( final List<E> list ) { | |
| 208 | return new SimpleListProperty<>( observableArrayList( list ) ); | |
| 209 | } | |
| 210 | ||
| 211 | private static StringProperty asStringProperty( final String value ) { | |
| 212 | return new SimpleStringProperty( value ); | |
| 213 | } | |
| 214 | ||
| 215 | private static BooleanProperty asBooleanProperty() { | |
| 216 | return new SimpleBooleanProperty(); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * @param value Default value. | |
| 221 | */ | |
| 222 | @SuppressWarnings( "SameParameterValue" ) | |
| 223 | private static BooleanProperty asBooleanProperty( final boolean value ) { | |
| 224 | return new SimpleBooleanProperty( value ); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * @param value Default value. | |
| 229 | */ | |
| 230 | @SuppressWarnings( "SameParameterValue" ) | |
| 231 | private static IntegerProperty asIntegerProperty( final int value ) { | |
| 232 | return new SimpleIntegerProperty( value ); | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * @param value Default value. | |
| 237 | */ | |
| 238 | private static DoubleProperty asDoubleProperty( final double value ) { | |
| 239 | return new SimpleDoubleProperty( value ); | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * @param value Default value. | |
| 244 | */ | |
| 245 | private static FileProperty asFileProperty( final File value ) { | |
| 246 | return new FileProperty( value ); | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * @param value Default value. | |
| 251 | */ | |
| 252 | @SuppressWarnings( "SameParameterValue" ) | |
| 253 | private static LocaleProperty asLocaleProperty( final Locale value ) { | |
| 254 | return new LocaleProperty( value ); | |
| 255 | } | |
| 256 | ||
| 257 | /** | |
| 258 | * @param value Default value. | |
| 259 | */ | |
| 260 | @SuppressWarnings( "SameParameterValue" ) | |
| 261 | private static SkinProperty asSkinProperty( final String value ) { | |
| 262 | return new SkinProperty( value ); | |
| 263 | } | |
| 264 | ||
| 265 | /** | |
| 266 | * Creates a new {@link Workspace} that will attempt to load the users' | |
| 267 | * preferences. If the configuration file cannot be loaded, the workspace | |
| 268 | * settings returns default values. | |
| 269 | */ | |
| 270 | public Workspace() { | |
| 271 | load(); | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Attempts to load the app's configuration file. | |
| 276 | */ | |
| 277 | private void load() { | |
| 278 | final var store = createXmlStore(); | |
| 279 | store.load( FILE_PREFERENCES ); | |
| 280 | ||
| 281 | mValues.keySet().forEach( key -> { | |
| 282 | try { | |
| 283 | final var storeValue = store.getValue( key ); | |
| 284 | final var property = valuesProperty( key ); | |
| 285 | ||
| 286 | property.setValue( unmarshall( property, storeValue ) ); | |
| 287 | } catch( final NoSuchElementException ignored ) { | |
| 288 | // When no configuration (item), use the default value. | |
| 289 | } | |
| 290 | } ); | |
| 291 | ||
| 292 | mSets.keySet().forEach( key -> { | |
| 293 | final var set = store.getSet( key ); | |
| 294 | final SetProperty<String> property = setsProperty( key ); | |
| 295 | ||
| 296 | property.setValue( observableSet( set ) ); | |
| 297 | } ); | |
| 298 | ||
| 299 | mLists.keySet().forEach( key -> { | |
| 300 | final var map = store.getMap( key ); | |
| 301 | final ListProperty<Entry<String, String>> property = listsProperty( key ); | |
| 302 | final var list = map | |
| 303 | .entrySet() | |
| 304 | .stream() | |
| 305 | .toList(); | |
| 306 | ||
| 307 | property.setValue( observableArrayList( list ) ); | |
| 308 | } ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Saves the current workspace. | |
| 313 | */ | |
| 314 | public void save() { | |
| 315 | final var store = createXmlStore(); | |
| 316 | ||
| 317 | try { | |
| 318 | // Update the string values to include the application version. | |
| 319 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 320 | ||
| 321 | mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | |
| 322 | mSets.forEach( store::setSet ); | |
| 323 | mLists.forEach( store::setMap ); | |
| 324 | ||
| 325 | store.save( FILE_PREFERENCES ); | |
| 326 | } catch( final Exception ex ) { | |
| 327 | clue( ex ); | |
| 328 | } | |
| 329 | } | |
| 330 | ||
| 331 | /** | |
| 332 | * Returns a value that represents a setting in the application that the user | |
| 333 | * may configure, either directly or indirectly. | |
| 334 | * | |
| 335 | * @param key The reference to the users' preference stored in deference | |
| 336 | * of app reëntrance. | |
| 337 | * @return An observable property to be persisted. | |
| 338 | */ | |
| 339 | @SuppressWarnings( "unchecked" ) | |
| 340 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 341 | assert key != null; | |
| 342 | return (U) mValues.get( key ); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Returns a set of values that represent a setting in the application that | |
| 347 | * the user may configure, either directly or indirectly. The property | |
| 348 | * returned is backed by a {@link Set}. | |
| 349 | * | |
| 350 | * @param key The {@link Key} associated with a preference value. | |
| 351 | * @return An observable property to be persisted. | |
| 352 | */ | |
| 353 | @SuppressWarnings( "unchecked" ) | |
| 354 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 355 | assert key != null; | |
| 356 | return (SetProperty<T>) mSets.get( key ); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Returns a list of values that represent a setting in the application that | |
| 361 | * the user may configure, either directly or indirectly. The property | |
| 362 | * returned is backed by a mutable {@link List}. | |
| 363 | * | |
| 364 | * @param key The {@link Key} associated with a preference value. | |
| 365 | * @return An observable property to be persisted. | |
| 366 | */ | |
| 367 | @SuppressWarnings( "unchecked" ) | |
| 368 | public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | |
| 369 | assert key != null; | |
| 370 | return (ListProperty<Entry<K, V>>) mLists.get( key ); | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Returns the {@link String} {@link Property} associated with the given | |
| 375 | * {@link Key} from the internal list of preference values. The caller | |
| 376 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 377 | * {@link Property}. | |
| 378 | * | |
| 379 | * @param key The {@link Key} associated with a preference value. | |
| 380 | * @return The value associated with the given {@link Key}. | |
| 381 | */ | |
| 382 | public StringProperty stringProperty( final Key key ) { | |
| 383 | assert key != null; | |
| 384 | return valuesProperty( key ); | |
| 385 | } | |
| 386 | ||
| 387 | /** | |
| 388 | * Returns the {@link Boolean} {@link Property} associated with the given | |
| 389 | * {@link Key} from the internal list of preference values. The caller | |
| 390 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 391 | * {@link Property}. | |
| 392 | * | |
| 393 | * @param key The {@link Key} associated with a preference value. | |
| 394 | * @return The value associated with the given {@link Key}. | |
| 395 | */ | |
| 396 | public BooleanProperty booleanProperty( final Key key ) { | |
| 397 | assert key != null; | |
| 398 | return valuesProperty( key ); | |
| 399 | } | |
| 400 | ||
| 401 | /** | |
| 402 | * Returns the {@link Integer} {@link Property} associated with the given | |
| 403 | * {@link Key} from the internal list of preference values. The caller | |
| 404 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 405 | * {@link Property}. | |
| 406 | * | |
| 407 | * @param key The {@link Key} associated with a preference value. | |
| 408 | * @return The value associated with the given {@link Key}. | |
| 409 | */ | |
| 410 | public IntegerProperty integerProperty( final Key key ) { | |
| 411 | assert key != null; | |
| 412 | return valuesProperty( key ); | |
| 413 | } | |
| 414 | ||
| 415 | /** | |
| 416 | * Returns the {@link Double} {@link Property} associated with the given | |
| 417 | * {@link Key} from the internal list of preference values. The caller | |
| 418 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 419 | * {@link Property}. | |
| 420 | * | |
| 421 | * @param key The {@link Key} associated with a preference value. | |
| 422 | * @return The value associated with the given {@link Key}. | |
| 423 | */ | |
| 424 | public DoubleProperty doubleProperty( final Key key ) { | |
| 425 | assert key != null; | |
| 426 | return valuesProperty( key ); | |
| 427 | } | |
| 428 | ||
| 429 | /** | |
| 430 | * Returns the {@link File} {@link Property} associated with the given | |
| 431 | * {@link Key} from the internal list of preference values. The caller | |
| 432 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 433 | * {@link Property}. | |
| 434 | * | |
| 435 | * @param key The {@link Key} associated with a preference value. | |
| 436 | * @return The value associated with the given {@link Key}. | |
| 437 | */ | |
| 438 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 439 | assert key != null; | |
| 440 | return valuesProperty( key ); | |
| 441 | } | |
| 442 | ||
| 443 | /** | |
| 444 | * Returns the {@link Locale} {@link Property} associated with the given | |
| 445 | * {@link Key} from the internal list of preference values. The caller | |
| 446 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 447 | * {@link Property}. | |
| 448 | * | |
| 449 | * @param key The {@link Key} associated with a preference value. | |
| 450 | * @return The value associated with the given {@link Key}. | |
| 451 | */ | |
| 452 | public LocaleProperty localeProperty( final Key key ) { | |
| 453 | assert key != null; | |
| 454 | return valuesProperty( key ); | |
| 455 | } | |
| 456 | ||
| 457 | public ObjectProperty<String> skinProperty( final Key key ) { | |
| 458 | assert key != null; | |
| 459 | return valuesProperty( key ); | |
| 460 | } | |
| 461 | ||
| 462 | @Override | |
| 463 | public String getString( final Key key ) { | |
| 464 | assert key != null; | |
| 465 | return stringProperty( key ).get(); | |
| 466 | } | |
| 467 | ||
| 468 | /** | |
| 469 | * Returns the {@link Boolean} preference value associated with the given | |
| 470 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 471 | * associated with a value that matches the return type. | |
| 472 | * | |
| 473 | * @param key The {@link Key} associated with a preference value. | |
| 474 | * @return The value associated with the given {@link Key}. | |
| 475 | */ | |
| 476 | @Override | |
| 477 | public boolean getBoolean( final Key key ) { | |
| 478 | assert key != null; | |
| 479 | return booleanProperty( key ).get(); | |
| 480 | } | |
| 481 | ||
| 482 | /** | |
| 483 | * Returns the {@link Integer} preference value associated with the given | |
| 484 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 485 | * associated with a value that matches the return type. | |
| 486 | * | |
| 487 | * @param key The {@link Key} associated with a preference value. | |
| 488 | * @return The value associated with the given {@link Key}. | |
| 489 | */ | |
| 490 | @Override | |
| 491 | public int getInteger( final Key key ) { | |
| 492 | assert key != null; | |
| 493 | return integerProperty( key ).get(); | |
| 494 | } | |
| 495 | ||
| 496 | /** | |
| 497 | * Returns the {@link Double} preference value associated with the given | |
| 498 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 499 | * associated with a value that matches the return type. | |
| 500 | * | |
| 501 | * @param key The {@link Key} associated with a preference value. | |
| 502 | * @return The value associated with the given {@link Key}. | |
| 503 | */ | |
| 504 | @Override | |
| 505 | public double getDouble( final Key key ) { | |
| 506 | assert key != null; | |
| 507 | return doubleProperty( key ).get(); | |
| 508 | } | |
| 509 | ||
| 510 | /** | |
| 511 | * Returns the {@link File} preference value associated with the given | |
| 512 | * {@link Key}. The caller must be sure that the given {@link Key} is | |
| 513 | * associated with a value that matches the return type. | |
| 514 | * | |
| 515 | * @param key The {@link Key} associated with a preference value. | |
| 516 | * @return The value associated with the given {@link Key}. | |
| 517 | */ | |
| 518 | @Override | |
| 519 | public File getFile( final Key key ) { | |
| 520 | assert key != null; | |
| 521 | return fileProperty( key ).get(); | |
| 522 | } | |
| 523 | ||
| 524 | /** | |
| 525 | * Returns the language locale setting for the | |
| 526 | * {@link AppKeys#KEY_LANGUAGE_LOCALE} key. | |
| 527 | * | |
| 528 | * @return The user's current locale setting. | |
| 529 | */ | |
| 530 | public Locale getLocale() { | |
| 531 | return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale(); | |
| 532 | } | |
| 533 | ||
| 534 | public SigilKeyOperator createDefinitionKeyOperator() { | |
| 535 | final var began = getString( KEY_DEF_DELIM_BEGAN ); | |
| 536 | final var ended = getString( KEY_DEF_DELIM_ENDED ); | |
| 537 | ||
| 538 | return new SigilKeyOperator( began, ended ); | |
| 539 | } | |
| 540 | ||
| 541 | public SigilKeyOperator createPropertyKeyOperator() { | |
| 542 | return new PropertyKeyOperator(); | |
| 543 | } | |
| 544 | ||
| 545 | /** | |
| 546 | * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)}, | |
| 547 | * providing a value of {@code true} for the {@link BooleanSupplier} to | |
| 548 | * indicate the property changes always take effect. | |
| 549 | * | |
| 550 | * @param key The value to bind to the internal key property. | |
| 551 | * @param property The external property value that sets the internal value. | |
| 552 | */ | |
| 553 | public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) { | |
| 554 | assert key != null; | |
| 555 | assert property != null; | |
| 556 | ||
| 557 | listen( key, property, () -> true ); | |
| 558 | } | |
| 559 | ||
| 560 | /** | |
| 561 | * Binds a read-only property to a value in the preferences. This allows | |
| 562 | * user interface properties to change and the preferences will be | |
| 563 | * synchronized automatically. | |
| 564 | * <p> | |
| 565 | * This calls {@link Platform#runLater(Runnable)} to ensure that all pending | |
| 566 | * application window states are finished before assessing whether property | |
| 567 | * changes should be applied. Without this, exiting the application while the | |
| 568 | * window is maximized would persist the window's maximum dimensions, | |
| 569 | * preventing restoration to its prior, non-maximum size. | |
| 570 | * | |
| 571 | * @param key The value to bind to the internal key property. | |
| 572 | * @param property The external property value that sets the internal value. | |
| 573 | * @param enabled Indicates whether property changes should be applied. | |
| 574 | */ | |
| 575 | public <T> void listen( | |
| 576 | final Key key, | |
| 577 | final ReadOnlyProperty<T> property, | |
| 578 | final BooleanSupplier enabled ) { | |
| 579 | assert key != null; | |
| 580 | assert property != null; | |
| 581 | assert enabled != null; | |
| 582 | ||
| 583 | property.addListener( | |
| 584 | ( c, o, n ) -> runLater( () -> { | |
| 585 | if( enabled.getAsBoolean() ) { | |
| 586 | valuesProperty( key ).setValue( n ); | |
| 587 | } | |
| 588 | } ) | |
| 589 | ); | |
| 590 | } | |
| 591 | ||
| 592 | /** | |
| 593 | * Returns the sigil operator for the given {@link MediaType}. | |
| 594 | * | |
| 595 | * @param mediaType The type of file being edited. | |
| 596 | */ | |
| 597 | public SigilKeyOperator createSigilOperator( final MediaType mediaType ) { | |
| 598 | assert mediaType != null; | |
| 599 | ||
| 600 | return mediaType == MediaType.TEXT_PROPERTIES | |
| 601 | ? createPropertyKeyOperator() | |
| 602 | : createDefinitionKeyOperator(); | |
| 603 | } | |
| 604 | ||
| 605 | /** | |
| 606 | * Returns the sigil operator for the given {@link Path}. | |
| 607 | * | |
| 608 | * @param path The type of file being edited, from its extension. | |
| 609 | */ | |
| 610 | public SigilKeyOperator createSigilOperator( final Path path ) { | |
| 611 | assert path != null; | |
| 612 | ||
| 613 | return createSigilOperator( MediaType.valueFrom( path ) ); | |
| 614 | } | |
| 615 | ||
| 616 | /** | |
| 617 | * Creates a lightweight persistence mechanism for user preferences. | |
| 618 | * | |
| 619 | * @return The {@link XmlStore} that helps with persisting application state. | |
| 620 | */ | |
| 621 | private XmlStore createXmlStore() { | |
| 622 | // Root-level configuration item is the application name. | |
| 623 | return new XmlStore( APP_TITLE_LOWERCASE ); | |
| 515 | 624 | } |
| 516 | 625 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import static com.keenwrite.preferences.Key.key; | |
| 5 | ||
| 6 | /** | |
| 7 | * Responsible for defining constants used throughout the application that | |
| 8 | * represent persisted preferences. | |
| 9 | */ | |
| 10 | public final class WorkspaceKeys { | |
| 11 | //@formatter:off | |
| 12 | private static final Key KEY_ROOT = key( "workspace" ); | |
| 13 | ||
| 14 | public static final Key KEY_META = key( KEY_ROOT, "meta" ); | |
| 15 | public static final Key KEY_META_NAME = key( KEY_META, "name" ); | |
| 16 | public static final Key KEY_META_VERSION = key( KEY_META, "version" ); | |
| 17 | ||
| 18 | public static final Key KEY_DOC = key( KEY_ROOT, "document" ); | |
| 19 | public static final Key KEY_DOC_TITLE = key( KEY_DOC, "title" ); | |
| 20 | public static final Key KEY_DOC_AUTHOR = key( KEY_DOC, "author" ); | |
| 21 | public static final Key KEY_DOC_BYLINE = key( KEY_DOC, "byline" ); | |
| 22 | public static final Key KEY_DOC_ADDRESS = key( KEY_DOC, "address" ); | |
| 23 | public static final Key KEY_DOC_PHONE = key( KEY_DOC, "phone" ); | |
| 24 | public static final Key KEY_DOC_EMAIL = key( KEY_DOC, "email" ); | |
| 25 | public static final Key KEY_DOC_KEYWORDS = key( KEY_DOC, "keywords" ); | |
| 26 | public static final Key KEY_DOC_DATE = key( KEY_DOC, "date" ); | |
| 27 | public static final Key KEY_DOC_COPYRIGHT = key( KEY_DOC, "copyright" ); | |
| 28 | ||
| 29 | public static final Key KEY_EDITOR = key( KEY_ROOT, "editor" ); | |
| 30 | public static final Key KEY_EDITOR_AUTOSAVE = key( KEY_EDITOR, "autosave" ); | |
| 31 | ||
| 32 | public static final Key KEY_R = key( KEY_ROOT, "r" ); | |
| 33 | public static final Key KEY_R_SCRIPT = key( KEY_R, "script" ); | |
| 34 | public static final Key KEY_R_DIR = key( KEY_R, "dir" ); | |
| 35 | public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" ); | |
| 36 | public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" ); | |
| 37 | public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" ); | |
| 38 | ||
| 39 | public static final Key KEY_IMAGES = key( KEY_ROOT, "images" ); | |
| 40 | public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" ); | |
| 41 | public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" ); | |
| 42 | public static final Key KEY_IMAGES_RESIZE = key( KEY_IMAGES, "resize" ); | |
| 43 | public static final Key KEY_IMAGES_SERVER = key( KEY_IMAGES, "server" ); | |
| 44 | ||
| 45 | public static final Key KEY_DEF = key( KEY_ROOT, "definition" ); | |
| 46 | public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" ); | |
| 47 | public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" ); | |
| 48 | public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" ); | |
| 49 | public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" ); | |
| 50 | ||
| 51 | public static final Key KEY_UI = key( KEY_ROOT, "ui" ); | |
| 52 | ||
| 53 | public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" ); | |
| 54 | public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" ); | |
| 55 | public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" ); | |
| 56 | public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" ); | |
| 57 | public static final Key KEY_UI_RECENT_EXPORT = key( KEY_UI_RECENT, "export" ); | |
| 58 | ||
| 59 | public static final Key KEY_UI_FILES = key( KEY_UI, "files" ); | |
| 60 | public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" ); | |
| 61 | ||
| 62 | public static final Key KEY_UI_FONT = key( KEY_UI, "font" ); | |
| 63 | public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" ); | |
| 64 | public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" ); | |
| 65 | public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" ); | |
| 66 | public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" ); | |
| 67 | public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" ); | |
| 68 | public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" ); | |
| 69 | public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" ); | |
| 70 | public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" ); | |
| 71 | public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" ); | |
| 72 | ||
| 73 | public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" ); | |
| 74 | public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" ); | |
| 75 | public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" ); | |
| 76 | public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" ); | |
| 77 | public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" ); | |
| 78 | public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" ); | |
| 79 | public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" ); | |
| 80 | ||
| 81 | public static final Key KEY_UI_SKIN = key( KEY_UI, "skin" ); | |
| 82 | public static final Key KEY_UI_SKIN_SELECTION = key( KEY_UI_SKIN, "selection" ); | |
| 83 | public static final Key KEY_UI_SKIN_CUSTOM = key( KEY_UI_SKIN, "custom" ); | |
| 84 | ||
| 85 | public static final Key KEY_UI_PREVIEW = key( KEY_UI, "preview" ); | |
| 86 | public static final Key KEY_UI_PREVIEW_STYLESHEET = key( KEY_UI_PREVIEW, "stylesheet" ); | |
| 87 | ||
| 88 | public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" ); | |
| 89 | public static final Key KEY_LANGUAGE_LOCALE = key( KEY_LANGUAGE, "locale" ); | |
| 90 | ||
| 91 | public static final Key KEY_TYPESET = key( KEY_ROOT, "typeset" ); | |
| 92 | public static final Key KEY_TYPESET_CONTEXT = key( KEY_TYPESET, "context" ); | |
| 93 | public static final Key KEY_TYPESET_CONTEXT_THEMES = key( KEY_TYPESET_CONTEXT, "themes" ); | |
| 94 | public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" ); | |
| 95 | public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" ); | |
| 96 | public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" ); | |
| 97 | public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" ); | |
| 98 | public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" ); | |
| 99 | //@formatter:on | |
| 100 | ||
| 101 | /** | |
| 102 | * Only for constants, do not instantiate. | |
| 103 | */ | |
| 104 | private WorkspaceKeys() { } | |
| 105 | } | |
| 106 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preferences; | |
| 3 | ||
| 4 | import com.dlsc.preferencesfx.PreferencesFx; | |
| 5 | import com.dlsc.preferencesfx.util.StorageHandler; | |
| 6 | import javafx.collections.ObservableList; | |
| 7 | ||
| 8 | import java.util.prefs.Preferences; | |
| 9 | ||
| 10 | /** | |
| 11 | * Prevents {@link PreferencesFx} from saving. Saving and loading preferences | |
| 12 | * and application window state is accomplished by the {@link Workspace}. | |
| 13 | * <p> | |
| 14 | * This implies that undo/redo functionality must be disabled because the | |
| 15 | * {@link Workspace} does not preserve previous states. | |
| 16 | * </p> | |
| 17 | */ | |
| 18 | public final class XmlStorageHandler implements StorageHandler { | |
| 19 | @Override | |
| 20 | public void saveSelectedCategory( final String breadcrumb ) { } | |
| 21 | ||
| 22 | @Override | |
| 23 | public String loadSelectedCategory() { | |
| 24 | return ""; | |
| 25 | } | |
| 26 | ||
| 27 | @Override | |
| 28 | public void saveDividerPosition( final double dividerPosition ) { | |
| 29 | } | |
| 30 | ||
| 31 | @Override | |
| 32 | public double loadDividerPosition() { | |
| 33 | return 0; | |
| 34 | } | |
| 35 | ||
| 36 | @Override | |
| 37 | public void saveWindowWidth( final double windowWidth ) { } | |
| 38 | ||
| 39 | @Override | |
| 40 | public double loadWindowWidth() { | |
| 41 | return 0; | |
| 42 | } | |
| 43 | ||
| 44 | @Override | |
| 45 | public void saveWindowHeight( final double windowHeight ) { } | |
| 46 | ||
| 47 | @Override | |
| 48 | public double loadWindowHeight() { | |
| 49 | return 0; | |
| 50 | } | |
| 51 | ||
| 52 | @Override | |
| 53 | public void saveWindowPosX( final double windowPosX ) { } | |
| 54 | ||
| 55 | @Override | |
| 56 | public double loadWindowPosX() { | |
| 57 | return 0; | |
| 58 | } | |
| 59 | ||
| 60 | @Override | |
| 61 | public void saveWindowPosY( final double windowPosY ) { } | |
| 62 | ||
| 63 | @Override | |
| 64 | public double loadWindowPosY() { | |
| 65 | return 0; | |
| 66 | } | |
| 67 | ||
| 68 | @Override | |
| 69 | public void saveObject( final String breadcrumb, final Object object ) { } | |
| 70 | ||
| 71 | @Override | |
| 72 | public Object loadObject( | |
| 73 | final String breadcrumb, final Object defaultObject ) { | |
| 74 | return defaultObject; | |
| 75 | } | |
| 76 | ||
| 77 | @Override | |
| 78 | public <T> T loadObject( | |
| 79 | final String breadcrumb, final Class<T> type, final T defaultObject ) { | |
| 80 | return defaultObject; | |
| 81 | } | |
| 82 | ||
| 83 | @Override | |
| 84 | @SuppressWarnings("rawtypes") | |
| 85 | public ObservableList loadObservableList( | |
| 86 | final String breadcrumb, final ObservableList defaultObservableList ) { | |
| 87 | return defaultObservableList; | |
| 88 | } | |
| 89 | ||
| 90 | @Override | |
| 91 | public <T> ObservableList<T> loadObservableList( | |
| 92 | final String breadcrumb, | |
| 93 | final Class<T> type, | |
| 94 | final ObservableList<T> defaultObservableList ) { | |
| 95 | return defaultObservableList; | |
| 96 | } | |
| 97 | ||
| 98 | @Override | |
| 99 | public boolean clearPreferences() { | |
| 100 | return false; | |
| 101 | } | |
| 102 | ||
| 103 | @Override | |
| 104 | public Preferences getPreferences() { | |
| 105 | return null; | |
| 106 | } | |
| 107 | } | |
| 108 | 1 |
| 1 | package com.keenwrite.preferences; | |
| 2 | ||
| 3 | import com.keenwrite.dom.DocumentParser; | |
| 4 | import javafx.beans.property.ListProperty; | |
| 5 | import javafx.beans.property.SetProperty; | |
| 6 | import org.w3c.dom.Document; | |
| 7 | import org.w3c.dom.Element; | |
| 8 | import org.w3c.dom.Node; | |
| 9 | ||
| 10 | import javax.xml.xpath.XPath; | |
| 11 | import javax.xml.xpath.XPathExpression; | |
| 12 | import javax.xml.xpath.XPathExpressionException; | |
| 13 | import java.io.File; | |
| 14 | import java.io.FileWriter; | |
| 15 | import java.io.IOException; | |
| 16 | import java.util.*; | |
| 17 | import java.util.Map.Entry; | |
| 18 | import java.util.function.Consumer; | |
| 19 | ||
| 20 | import static javax.xml.xpath.XPathConstants.NODE; | |
| 21 | ||
| 22 | /** | |
| 23 | * Responsible for managing XML documents, which includes reading, writing, | |
| 24 | * retrieving, and setting elements. This is an alternative to Apache | |
| 25 | * Commons Configuration, JAXB, and Jackson. All of them are heavyweight and | |
| 26 | * the latter are difficult to use with dynamic data (because they require | |
| 27 | * annotations). | |
| 28 | * <p> | |
| 29 | * <strong>Note:</strong> It is preferable to use a different instance when | |
| 30 | * loading and saving the documents. Otherwise, old and duplicate data will | |
| 31 | * persist. Using a new instance ensures that elements removed from the | |
| 32 | * user preferences will not persist across XML configuration file versions. | |
| 33 | */ | |
| 34 | public class XmlStore { | |
| 35 | private static final String SEPARATOR = "/"; | |
| 36 | ||
| 37 | private final String mRoot; | |
| 38 | private Document mDocument = DocumentParser.newDocument(); | |
| 39 | ||
| 40 | /** | |
| 41 | * Constructs a new instance with a blank {@link Document}. Call the | |
| 42 | * {@link #load(File)} method to populate the document from a {@link File}, | |
| 43 | * or {@link #save(File)} to persist the current document state. | |
| 44 | * | |
| 45 | * @param root The root-level document element. | |
| 46 | */ | |
| 47 | public XmlStore( final String root ) { | |
| 48 | assert root != null; | |
| 49 | ||
| 50 | mRoot = root; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Loads the given configuration file into a document object model. | |
| 55 | * Clients of this class can set and retrieve elements via the requisite | |
| 56 | * access methods. | |
| 57 | * | |
| 58 | * @param config File containing persistent user preferences. | |
| 59 | */ | |
| 60 | public void load( final File config ) { | |
| 61 | assert config != null; | |
| 62 | assert config.isFile(); | |
| 63 | ||
| 64 | try { | |
| 65 | mDocument = DocumentParser.parse( config ); | |
| 66 | } catch( final Exception ignored ) { | |
| 67 | mDocument = DocumentParser.newDocument(); | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Returns the document value associated with the given key name. | |
| 73 | * | |
| 74 | * @param key {@link Key} name to retrieve. | |
| 75 | * @return The value associated with the key. | |
| 76 | * @throws NoSuchElementException No value could be found for the key. | |
| 77 | */ | |
| 78 | public String getValue( final Key key ) throws NoSuchElementException { | |
| 79 | assert key != null; | |
| 80 | ||
| 81 | try { | |
| 82 | final var node = toNode( key, mDocument ); | |
| 83 | ||
| 84 | if( node != null ) { | |
| 85 | return node.getTextContent(); | |
| 86 | } | |
| 87 | } catch( final XPathExpressionException ignored ) {} | |
| 88 | ||
| 89 | throw new NoSuchElementException( key.toString() ); | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Returns a set of document values associated with the given key name. This | |
| 94 | * is suitable for basic sets, such as: | |
| 95 | * <pre> | |
| 96 | * {@code | |
| 97 | * <recent> | |
| 98 | * <file>/tmp/filename.txt</file> | |
| 99 | * <file>/home/username/document.md</file> | |
| 100 | * <file>/usr/local/share/app/conf/help.Rmd</file> | |
| 101 | * </recent>} | |
| 102 | * </pre> | |
| 103 | * <p> | |
| 104 | * The {@code file} element name can be ignored. | |
| 105 | * | |
| 106 | * @param key {@link Key} name to retrieve. | |
| 107 | * @return The values associated with the key, or an empty set if none found. | |
| 108 | */ | |
| 109 | public Set<String> getSet( final Key key ) { | |
| 110 | assert key != null; | |
| 111 | ||
| 112 | final var set = new LinkedHashSet<String>(); | |
| 113 | ||
| 114 | visit( key, node -> set.add( node.getTextContent() ) ); | |
| 115 | ||
| 116 | return set; | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Returns a map of name/value pairs associated with the given key name. | |
| 121 | * This is suitable for mapped values, such as: | |
| 122 | * <pre> | |
| 123 | * {@code | |
| 124 | * <meta> | |
| 125 | * <title>{{book.title}}</title> | |
| 126 | * <author>{{book.author}}</author> | |
| 127 | * <date>{{book.publish.date}}</date> | |
| 128 | * </meta>} | |
| 129 | * </pre> | |
| 130 | * <p> | |
| 131 | * The element names under the {@code meta} node must be preserved along | |
| 132 | * with their values. Resolving the values based on the variable definitions | |
| 133 | * (in moustache syntax) is not a responsibility of this class. | |
| 134 | * | |
| 135 | * @param key {@link Key} name to retrieve (e.g., {@code meta}). | |
| 136 | * @return A map of element names to element values, or an empty map if | |
| 137 | * none found. | |
| 138 | */ | |
| 139 | public Map<String, String> getMap( final Key key ) { | |
| 140 | assert key != null; | |
| 141 | ||
| 142 | // Create a new key that will match all child nodes under the given key, | |
| 143 | // extracting each element as a name/value pair for the resulting map. | |
| 144 | final var all = Key.key( key, "*" ); | |
| 145 | final var map = new LinkedHashMap<String, String>(); | |
| 146 | ||
| 147 | visit( all, node -> map.put( node.getNodeName(), node.getTextContent() ) ); | |
| 148 | ||
| 149 | return map; | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Call to write the user preferences to a file. | |
| 154 | * | |
| 155 | * @param config The file wherein the preferences are saved. | |
| 156 | * @throws IOException Could not write to the file. | |
| 157 | */ | |
| 158 | public void save( final File config ) throws IOException { | |
| 159 | assert config != null; | |
| 160 | ||
| 161 | try( final var writer = new FileWriter( config ) ) { | |
| 162 | writer.write( DocumentParser.toString( mDocument ) ); | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | public void setValue( final Key key, final String value ) { | |
| 167 | assert key != null; | |
| 168 | assert value != null; | |
| 169 | ||
| 170 | try { | |
| 171 | final var node = upsert( key, mDocument ); | |
| 172 | ||
| 173 | node.setTextContent( value ); | |
| 174 | } catch( final XPathExpressionException ignored ) {} | |
| 175 | } | |
| 176 | ||
| 177 | public void setSet( final Key key, final SetProperty<?> set ) { | |
| 178 | assert key != null; | |
| 179 | assert set != null; | |
| 180 | ||
| 181 | Node node = null; | |
| 182 | ||
| 183 | try { | |
| 184 | for( final var item : set ) { | |
| 185 | if( node == null ) { | |
| 186 | node = upsert( key, mDocument ); | |
| 187 | } | |
| 188 | else { | |
| 189 | final var doc = node.getOwnerDocument(); | |
| 190 | final var sibling = doc.createElement( key.name() ); | |
| 191 | var parent = node.getParentNode(); | |
| 192 | ||
| 193 | if( parent == null ) { | |
| 194 | parent = doc.getDocumentElement(); | |
| 195 | } | |
| 196 | ||
| 197 | parent.appendChild( sibling ); | |
| 198 | node = sibling; | |
| 199 | } | |
| 200 | ||
| 201 | node.setTextContent( item.toString() ); | |
| 202 | } | |
| 203 | } catch( final XPathExpressionException ignored ) {} | |
| 204 | } | |
| 205 | ||
| 206 | /** | |
| 207 | * @param key The application key representing a user preference. | |
| 208 | * @param list List of {@link Entry} items. | |
| 209 | */ | |
| 210 | public void setMap( final Key key, final ListProperty<?> list ) { | |
| 211 | assert key != null; | |
| 212 | assert list != null; | |
| 213 | ||
| 214 | for( final var item : list ) { | |
| 215 | if( item instanceof Entry entry ) { | |
| 216 | try { | |
| 217 | final var child = Key.key( key, entry.getKey().toString() ); | |
| 218 | final var node = upsert( child, mDocument ); | |
| 219 | ||
| 220 | node.setTextContent( entry.getValue().toString() ); | |
| 221 | } catch( final XPathExpressionException ignored ) {} | |
| 222 | } | |
| 223 | } | |
| 224 | } | |
| 225 | ||
| 226 | private Node toNode( final Key key, final Document doc ) | |
| 227 | throws XPathExpressionException { | |
| 228 | final var xpath = toXPath( key ); | |
| 229 | final var expr = DocumentParser.compile( xpath ); | |
| 230 | final var element = expr.evaluate( doc, NODE ); | |
| 231 | ||
| 232 | return element instanceof Node node ? node : null; | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Provides the equivalent of update-or-insert behaviour provided by some | |
| 237 | * SQL databases. Finds the element in the document represented by the | |
| 238 | * given {@link Key}. If no element is found then the full path to the | |
| 239 | * element is created. In essence, this method converts a hierarchy of | |
| 240 | * {@link Key} names into a hierarchy of {@link Document} {@link Element}s | |
| 241 | * (i.e., {@link Node}s). | |
| 242 | * <p> | |
| 243 | * For example, given a key named {@code workspace.meta.version}, this will | |
| 244 | * produce a document structure that, when exported as XML, resembles: | |
| 245 | * <pre>{@code | |
| 246 | * <root> | |
| 247 | * <workspace> | |
| 248 | * <meta> | |
| 249 | * <version/> | |
| 250 | * </meta> | |
| 251 | * </workspace> | |
| 252 | * </root> | |
| 253 | * }</pre> | |
| 254 | * <p> | |
| 255 | * The calling code is responsible for populating the {@link Node} returned | |
| 256 | * with its particular value. In the example above, the text content of the | |
| 257 | * {@link Node} would be filled with the application version number. | |
| 258 | * | |
| 259 | * @param key The application key representing a user preference. | |
| 260 | * @param doc The document that may contain an xpath for the {@link Key}. | |
| 261 | * @return The existing or new element. | |
| 262 | */ | |
| 263 | private Node upsert( final Key key, final Document doc ) | |
| 264 | throws XPathExpressionException { | |
| 265 | assert key != null; | |
| 266 | assert doc != null; | |
| 267 | ||
| 268 | final var missing = new Stack<Key>(); | |
| 269 | Key visitor = key; | |
| 270 | Node parent = null; | |
| 271 | ||
| 272 | do { | |
| 273 | final var node = toNode( visitor, doc ); | |
| 274 | ||
| 275 | // If an element exists on the first iteration, return it because there | |
| 276 | // is no missing hierarchy to create. | |
| 277 | if( node != null ) { | |
| 278 | if( missing.isEmpty() ) { | |
| 279 | return node; | |
| 280 | } | |
| 281 | ||
| 282 | parent = node; | |
| 283 | } | |
| 284 | else { | |
| 285 | // Track the number of elements in the hierarchy that don't exist. | |
| 286 | missing.push( visitor ); | |
| 287 | ||
| 288 | // Attempt to find the parent xpath in the document. | |
| 289 | visitor = visitor.parent(); | |
| 290 | } | |
| 291 | } | |
| 292 | while( visitor != null && parent == null ); | |
| 293 | ||
| 294 | // If the document is empty, update the top-level document element. | |
| 295 | if( parent == null ) { | |
| 296 | parent = doc.getDocumentElement(); | |
| 297 | ||
| 298 | // If there is still no top-level element, then create it. | |
| 299 | if( parent == null ) { | |
| 300 | parent = doc.createElement( mRoot ); | |
| 301 | doc.appendChild( parent ); | |
| 302 | } | |
| 303 | } | |
| 304 | ||
| 305 | assert parent != null; | |
| 306 | ||
| 307 | // Create the hierarchy. | |
| 308 | while( !missing.isEmpty() ) { | |
| 309 | visitor = missing.pop(); | |
| 310 | ||
| 311 | final var child = doc.createElement( visitor.name() ); | |
| 312 | parent.appendChild( child ); | |
| 313 | parent = child; | |
| 314 | } | |
| 315 | ||
| 316 | return parent; | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Abstraction for functionality that requires iterating over multiple | |
| 321 | * nodes under a particular xpath. | |
| 322 | * | |
| 323 | * @param key {@link #toXPath(Key) Compiled} into an {@link XPath}. | |
| 324 | * @param consumer Accepts each node that matches the {@link XPath}. | |
| 325 | */ | |
| 326 | private void visit( final Key key, final Consumer<Node> consumer ) { | |
| 327 | assert key != null; | |
| 328 | assert consumer != null; | |
| 329 | ||
| 330 | try { | |
| 331 | final var xpath = toXPath( key ); | |
| 332 | ||
| 333 | DocumentParser.visit( mDocument, xpath, consumer ); | |
| 334 | } catch( final XPathExpressionException ignored ) { | |
| 335 | // Programming error. Triggered by loading a previous config version? | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * Creates an {@link XPathExpression} value based on the given {@link Key}. | |
| 341 | * | |
| 342 | * @param key The {@link Key} to convert to an xpath string. | |
| 343 | * @return The given {@link Key} compiled into an {@link XPathExpression}. | |
| 344 | * @throws XPathExpressionException Could not compile the {@link Key}. | |
| 345 | */ | |
| 346 | private StringBuilder toXPath( final Key key ) | |
| 347 | throws XPathExpressionException { | |
| 348 | assert key != null; | |
| 349 | ||
| 350 | final var sb = new StringBuilder( 128 ); | |
| 351 | ||
| 352 | key.walk( sb::append, SEPARATOR ); | |
| 353 | sb.insert( 0, SEPARATOR ); | |
| 354 | ||
| 355 | if( !mRoot.isBlank() ) { | |
| 356 | sb.insert( 0, SEPARATOR + mRoot ); | |
| 357 | } | |
| 358 | ||
| 359 | return sb; | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Pretty-prints the XML document into a string. Meant to be used for | |
| 364 | * debugging. To save the configuration, see {@link #save(File)}. | |
| 365 | * | |
| 366 | * @return The document in a well-formed, indented, string format. | |
| 367 | */ | |
| 368 | @Override | |
| 369 | public String toString() { | |
| 370 | return DocumentParser.toString( mDocument ); | |
| 371 | } | |
| 372 | } | |
| 1 | 373 |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.ui.adapters.ReplacedElementAdapter; |
| 5 | import com.keenwrite.util.BoundedCache; | |
| 5 | import com.keenwrite.collections.BoundedCache; | |
| 6 | 6 | import org.w3c.dom.Element; |
| 7 | 7 | import org.xhtmlrenderer.extend.ReplacedElement; |
| 24 | 24 | import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent; |
| 25 | 25 | import static com.keenwrite.events.StatusEvent.clue; |
| 26 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 27 | import static com.keenwrite.ui.fonts.IconFactory.getIconFont; | |
| 28 | import static java.awt.BorderLayout.*; | |
| 29 | import static java.awt.event.KeyEvent.*; | |
| 30 | import static java.lang.String.format; | |
| 31 | import static javafx.scene.CacheHint.SPEED; | |
| 32 | import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW; | |
| 33 | import static javax.swing.KeyStroke.getKeyStroke; | |
| 34 | import static javax.swing.SwingUtilities.invokeLater; | |
| 35 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK; | |
| 36 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT; | |
| 37 | import static org.jsoup.Jsoup.parse; | |
| 38 | ||
| 39 | /** | |
| 40 | * Responsible for parsing an HTML document. | |
| 41 | */ | |
| 42 | public final class HtmlPreview extends SwingNode implements ComponentListener { | |
| 43 | /** | |
| 44 | * Converts a text string to a structured HTML document. | |
| 45 | */ | |
| 46 | private static final DocumentConverter CONVERTER = new DocumentConverter(); | |
| 47 | ||
| 48 | /** | |
| 49 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. | |
| 50 | */ | |
| 51 | private static final String HTML_STYLESHEET = | |
| 52 | "<link rel='stylesheet' href='%s'/>"; | |
| 53 | ||
| 54 | private static final String HTML_BASE = | |
| 55 | "<base href='%s'/>"; | |
| 56 | ||
| 57 | /** | |
| 58 | * Render CSS using points (pt) not pixels (px) to reduce the chance of | |
| 59 | * poor rendering. The {@link #generateHead()} method fills placeholders. | |
| 60 | * When the user has not set a locale, only one stylesheet is added to | |
| 61 | * the document. In order, the placeholders are as follows: | |
| 62 | * <ol> | |
| 63 | * <li>%s --- language</li> | |
| 64 | * <li>%s --- default stylesheet</li> | |
| 65 | * <li>%s --- language-specific stylesheet</li> | |
| 66 | * <li>%s --- user-customized stylesheet</li> | |
| 67 | * <li>%s --- font family</li> | |
| 68 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 69 | * <li>%s --- base href</li> | |
| 70 | * </p> | |
| 71 | */ | |
| 72 | private static final String HTML_HEAD = | |
| 73 | """ | |
| 74 | <!doctype html> | |
| 75 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 76 | %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 77 | """; | |
| 78 | ||
| 79 | private static final String HTML_TAIL = "</body></html>"; | |
| 80 | ||
| 81 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 82 | ||
| 83 | /** | |
| 84 | * Reusing this buffer prevents repetitious memory re-allocations. | |
| 85 | */ | |
| 86 | private final StringBuilder mDocument = new StringBuilder( 65536 ); | |
| 87 | ||
| 88 | private HtmlRenderer mPreview; | |
| 89 | private JScrollPane mScrollPane; | |
| 90 | private String mBaseUriPath = ""; | |
| 91 | private String mHead; | |
| 92 | ||
| 93 | private volatile boolean mLocked; | |
| 94 | private final JButton mScrollLockButton = new JButton(); | |
| 95 | private final Workspace mWorkspace; | |
| 96 | ||
| 97 | /** | |
| 98 | * Creates a new preview pane that can scroll to the caret position within the | |
| 99 | * document. | |
| 100 | * | |
| 101 | * @param workspace Contains locale and font size information. | |
| 102 | */ | |
| 103 | public HtmlPreview( final Workspace workspace ) { | |
| 104 | mWorkspace = workspace; | |
| 105 | mHead = generateHead(); | |
| 106 | ||
| 107 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 108 | setStyle( "-fx-background-color: white;" ); | |
| 109 | ||
| 110 | invokeLater( () -> { | |
| 111 | mPreview = new FlyingSaucerPanel(); | |
| 112 | mScrollPane = new JScrollPane( (Component) mPreview ); | |
| 113 | final var verticalBar = mScrollPane.getVerticalScrollBar(); | |
| 114 | final var verticalPanel = new JPanel( new BorderLayout() ); | |
| 115 | ||
| 116 | final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW ); | |
| 117 | addKeyboardEvents( map ); | |
| 118 | ||
| 119 | mScrollLockButton.setFont( getIconFont( 14 ) ); | |
| 120 | mScrollLockButton.setText( getLockText( mLocked ) ); | |
| 121 | mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) ); | |
| 122 | mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mLocked ) ); | |
| 123 | ||
| 124 | verticalPanel.add( verticalBar, CENTER ); | |
| 125 | verticalPanel.add( mScrollLockButton, PAGE_END ); | |
| 126 | ||
| 127 | final var wrapper = new JPanel( new BorderLayout() ); | |
| 128 | wrapper.add( mScrollPane, CENTER ); | |
| 129 | wrapper.add( verticalPanel, LINE_END ); | |
| 130 | ||
| 131 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 132 | setCache( true ); | |
| 133 | setCacheHint( SPEED ); | |
| 134 | setContent( wrapper ); | |
| 135 | wrapper.addComponentListener( this ); | |
| 136 | } ); | |
| 137 | ||
| 138 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 139 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 140 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 141 | ||
| 142 | register( this ); | |
| 143 | } | |
| 144 | ||
| 145 | @Subscribe | |
| 146 | public void handle( final ScrollLockEvent event ) { | |
| 147 | mLocked = event.isLocked(); | |
| 148 | invokeLater( () -> mScrollLockButton.setText( getLockText( mLocked ) ) ); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Updates the internal HTML source shown in the preview pane. | |
| 153 | * | |
| 154 | * @param html The new HTML document to display. | |
| 155 | */ | |
| 156 | public void render( final String html ) { | |
| 157 | final var doc = CONVERTER.fromJsoup( parse( decorate( html ) ) ); | |
| 158 | final var uri = getBaseUri(); | |
| 159 | doc.setDocumentURI( uri ); | |
| 160 | ||
| 161 | invokeLater( () -> mPreview.render( doc, uri ) ); | |
| 162 | ||
| 163 | DocumentChangedEvent.fire( html ); | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Clears the caches then re-renders the content. | |
| 168 | */ | |
| 169 | public void refresh() { | |
| 170 | mPreview.clearCache(); | |
| 171 | rerender(); | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Recomputes the HTML head then renders the document. | |
| 176 | */ | |
| 177 | private void rerender() { | |
| 178 | mHead = generateHead(); | |
| 179 | render( mDocument.toString() ); | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 184 | * string. | |
| 185 | * | |
| 186 | * @param html The HTML to adorn with opening and closing tags. | |
| 187 | * @return A complete HTML document, ready for rendering. | |
| 188 | */ | |
| 189 | private String decorate( final String html ) { | |
| 190 | mDocument.setLength( 0 ); | |
| 191 | mDocument.append( html ); | |
| 192 | ||
| 193 | // Head and tail must be separate from document due to re-rendering. | |
| 194 | return mHead + mDocument + HTML_TAIL; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Called when settings are changed that affect the HTML document preamble. | |
| 199 | * This is a minor performance optimization to avoid generating the head | |
| 200 | * each time that the document itself changes. | |
| 201 | * | |
| 202 | * @return A new doctype and HTML {@code head} element. | |
| 203 | */ | |
| 204 | private String generateHead() { | |
| 205 | final var locale = getLocale(); | |
| 206 | final var base = getBaseUri(); | |
| 207 | final var custom = getCustomStylesheetUrl(); | |
| 208 | ||
| 209 | // Point sizes are converted to pixels because of a rendering bug. | |
| 210 | return format( | |
| 211 | HTML_HEAD, | |
| 212 | locale.getLanguage(), | |
| 213 | toStylesheetString( HTML_STYLE_PREVIEW ), | |
| 214 | toStylesheetString( toUrl( locale ) ), | |
| 215 | toStylesheetString( custom ), | |
| 216 | getFontFamily(), | |
| 217 | toPixels( getFontSize() ), | |
| 218 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 219 | ); | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * Clears the preview pane by rendering an empty string. | |
| 224 | */ | |
| 225 | public void clear() { | |
| 226 | render( "" ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Sets the base URI to the containing directory the file being edited. | |
| 231 | * | |
| 232 | * @param path The path to the file being edited. | |
| 233 | */ | |
| 234 | public void setBaseUri( final Path path ) { | |
| 235 | final var parent = path.getParent(); | |
| 236 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Scrolls to the closest element matching the given identifier without | |
| 241 | * waiting for the document to be ready. | |
| 242 | * | |
| 243 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 244 | */ | |
| 245 | public void scrollTo( final String id ) { | |
| 246 | if( !mLocked ) { | |
| 247 | invokeLater( () -> { | |
| 248 | mPreview.scrollTo( id, mScrollPane ); | |
| 249 | mScrollPane.repaint(); | |
| 250 | } ); | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | private String getBaseUri() { | |
| 255 | return mBaseUriPath; | |
| 256 | } | |
| 257 | ||
| 258 | private JScrollPane getScrollPane() { | |
| 259 | return mScrollPane; | |
| 260 | } | |
| 261 | ||
| 262 | public JScrollBar getVerticalScrollBar() { | |
| 263 | return getScrollPane().getVerticalScrollBar(); | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 268 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 269 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 270 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 271 | * character set. | |
| 272 | * | |
| 273 | * @return Unique identifier for language and country. | |
| 274 | */ | |
| 275 | private static URL toUrl( final Locale locale ) { | |
| 276 | return toUrl( | |
| 277 | String.format( | |
| 278 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 279 | locale.getLanguage(), | |
| 280 | locale.getScript(), | |
| 281 | locale.getCountry() | |
| 282 | ) | |
| 283 | ); | |
| 284 | } | |
| 285 | ||
| 286 | private static URL toUrl( final String path ) { | |
| 287 | return HtmlPreview.class.getResource( path ); | |
| 288 | } | |
| 289 | ||
| 290 | private Locale getLocale() { | |
| 291 | return localeProperty().toLocale(); | |
| 292 | } | |
| 293 | ||
| 294 | private LocaleProperty localeProperty() { | |
| 295 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 296 | } | |
| 297 | ||
| 298 | private String getFontFamily() { | |
| 299 | return fontFamilyProperty().get(); | |
| 300 | } | |
| 301 | ||
| 302 | private StringProperty fontFamilyProperty() { | |
| 303 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 304 | } | |
| 305 | ||
| 306 | private double getFontSize() { | |
| 307 | return fontSizeProperty().get(); | |
| 308 | } | |
| 309 | ||
| 310 | /** | |
| 311 | * Returns the font size in points. | |
| 312 | * | |
| 313 | * @return The user-defined font size (in pt). | |
| 314 | */ | |
| 315 | private DoubleProperty fontSizeProperty() { | |
| 316 | return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ); | |
| 317 | } | |
| 318 | ||
| 319 | private String getLockText( final boolean locked ) { | |
| 320 | return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() ); | |
| 321 | } | |
| 322 | ||
| 323 | private URL getCustomStylesheetUrl() { | |
| 324 | try { | |
| 325 | return mWorkspace.toFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL(); | |
| 326 | } catch( final Exception ex ) { | |
| 327 | clue( ex ); | |
| 328 | return null; | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Maps keyboard events to scrollbar commands so that users may control | |
| 334 | * the {@link HtmlPreview} panel using the keyboard. | |
| 335 | * | |
| 336 | * @param map The map to update with keyboard events. | |
| 337 | */ | |
| 338 | private void addKeyboardEvents( final InputMap map ) { | |
| 339 | map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" ); | |
| 340 | map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" ); | |
| 341 | map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" ); | |
| 342 | map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" ); | |
| 343 | map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" ); | |
| 344 | map.put( getKeyStroke( VK_END, 0 ), "maxScroll" ); | |
| 345 | } | |
| 346 | ||
| 347 | @Override | |
| 348 | public void componentResized( final ComponentEvent e ) { | |
| 349 | if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) { | |
| 26 | import static com.keenwrite.preferences.AppKeys.*; | |
| 27 | import static com.keenwrite.ui.fonts.IconFactory.getIconFont; | |
| 28 | import static java.awt.BorderLayout.*; | |
| 29 | import static java.awt.event.KeyEvent.*; | |
| 30 | import static java.lang.String.format; | |
| 31 | import static javafx.scene.CacheHint.SPEED; | |
| 32 | import static javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW; | |
| 33 | import static javax.swing.KeyStroke.getKeyStroke; | |
| 34 | import static javax.swing.SwingUtilities.invokeLater; | |
| 35 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.LOCK; | |
| 36 | import static org.controlsfx.glyphfont.FontAwesome.Glyph.UNLOCK_ALT; | |
| 37 | import static org.jsoup.Jsoup.parse; | |
| 38 | ||
| 39 | /** | |
| 40 | * Responsible for parsing an HTML document. | |
| 41 | */ | |
| 42 | public final class HtmlPreview extends SwingNode implements ComponentListener { | |
| 43 | /** | |
| 44 | * Converts a text string to a structured HTML document. | |
| 45 | */ | |
| 46 | private static final DocumentConverter CONVERTER = new DocumentConverter(); | |
| 47 | ||
| 48 | /** | |
| 49 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. | |
| 50 | */ | |
| 51 | private static final String HTML_STYLESHEET = | |
| 52 | "<link rel='stylesheet' href='%s'/>"; | |
| 53 | ||
| 54 | private static final String HTML_BASE = | |
| 55 | "<base href='%s'/>"; | |
| 56 | ||
| 57 | /** | |
| 58 | * Render CSS using points (pt) not pixels (px) to reduce the chance of | |
| 59 | * poor rendering. The {@link #generateHead()} method fills placeholders. | |
| 60 | * When the user has not set a locale, only one stylesheet is added to | |
| 61 | * the document. In order, the placeholders are as follows: | |
| 62 | * <ol> | |
| 63 | * <li>%s --- language</li> | |
| 64 | * <li>%s --- default stylesheet</li> | |
| 65 | * <li>%s --- language-specific stylesheet</li> | |
| 66 | * <li>%s --- user-customized stylesheet</li> | |
| 67 | * <li>%s --- font family</li> | |
| 68 | * <li>%d --- font size (must be pixels, not points due to bug)</li> | |
| 69 | * <li>%s --- base href</li> | |
| 70 | * </p> | |
| 71 | */ | |
| 72 | private static final String HTML_HEAD = | |
| 73 | """ | |
| 74 | <!doctype html> | |
| 75 | <html lang='%s'><head><title> </title><meta charset='utf-8'/> | |
| 76 | %s%s%s<style>body{font-family:'%s';font-size: %dpx;}</style>%s</head><body> | |
| 77 | """; | |
| 78 | ||
| 79 | private static final String HTML_TAIL = "</body></html>"; | |
| 80 | ||
| 81 | private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW ); | |
| 82 | ||
| 83 | /** | |
| 84 | * Reusing this buffer prevents repetitious memory re-allocations. | |
| 85 | */ | |
| 86 | private final StringBuilder mDocument = new StringBuilder( 65536 ); | |
| 87 | ||
| 88 | private HtmlRenderer mPreview; | |
| 89 | private JScrollPane mScrollPane; | |
| 90 | private String mBaseUriPath = ""; | |
| 91 | private String mHead; | |
| 92 | ||
| 93 | private volatile boolean mScrollLocked; | |
| 94 | private final JButton mScrollLockButton = new JButton(); | |
| 95 | private final Workspace mWorkspace; | |
| 96 | ||
| 97 | /** | |
| 98 | * Creates a new preview pane that can scroll to the caret position within the | |
| 99 | * document. | |
| 100 | * | |
| 101 | * @param workspace Contains locale and font size information. | |
| 102 | */ | |
| 103 | public HtmlPreview( final Workspace workspace ) { | |
| 104 | mWorkspace = workspace; | |
| 105 | mHead = generateHead(); | |
| 106 | ||
| 107 | // Attempts to prevent a flash of black un-styled content upon load. | |
| 108 | setStyle( "-fx-background-color: white;" ); | |
| 109 | ||
| 110 | invokeLater( () -> { | |
| 111 | mPreview = new FlyingSaucerPanel(); | |
| 112 | mScrollPane = new JScrollPane( (Component) mPreview ); | |
| 113 | final var verticalBar = mScrollPane.getVerticalScrollBar(); | |
| 114 | final var verticalPanel = new JPanel( new BorderLayout() ); | |
| 115 | ||
| 116 | final var map = verticalBar.getInputMap( WHEN_IN_FOCUSED_WINDOW ); | |
| 117 | addKeyboardEvents( map ); | |
| 118 | ||
| 119 | mScrollLockButton.setFont( getIconFont( 14 ) ); | |
| 120 | mScrollLockButton.setText( getLockText( mScrollLocked ) ); | |
| 121 | mScrollLockButton.setMargin( new Insets( 1, 0, 0, 0 ) ); | |
| 122 | mScrollLockButton.addActionListener( e -> fireScrollLockEvent( !mScrollLocked ) ); | |
| 123 | ||
| 124 | verticalPanel.add( verticalBar, CENTER ); | |
| 125 | verticalPanel.add( mScrollLockButton, PAGE_END ); | |
| 126 | ||
| 127 | final var wrapper = new JPanel( new BorderLayout() ); | |
| 128 | wrapper.add( mScrollPane, CENTER ); | |
| 129 | wrapper.add( verticalPanel, LINE_END ); | |
| 130 | ||
| 131 | // Enabling the cache attempts to prevent black flashes when resizing. | |
| 132 | setCache( true ); | |
| 133 | setCacheHint( SPEED ); | |
| 134 | setContent( wrapper ); | |
| 135 | wrapper.addComponentListener( this ); | |
| 136 | } ); | |
| 137 | ||
| 138 | localeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 139 | fontFamilyProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 140 | fontSizeProperty().addListener( ( c, o, n ) -> rerender() ); | |
| 141 | ||
| 142 | register( this ); | |
| 143 | } | |
| 144 | ||
| 145 | @Subscribe | |
| 146 | public void handle( final ScrollLockEvent event ) { | |
| 147 | mScrollLocked = event.isLocked(); | |
| 148 | invokeLater( () -> mScrollLockButton.setText( getLockText( mScrollLocked ) ) ); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Updates the internal HTML source shown in the preview pane. | |
| 153 | * | |
| 154 | * @param html The new HTML document to display. | |
| 155 | */ | |
| 156 | public void render( final String html ) { | |
| 157 | final var doc = CONVERTER.fromJsoup( parse( decorate( html ) ) ); | |
| 158 | final var uri = getBaseUri(); | |
| 159 | doc.setDocumentURI( uri ); | |
| 160 | ||
| 161 | invokeLater( () -> mPreview.render( doc, uri ) ); | |
| 162 | ||
| 163 | DocumentChangedEvent.fire( html ); | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Clears the caches then re-renders the content. | |
| 168 | */ | |
| 169 | public void refresh() { | |
| 170 | mPreview.clearCache(); | |
| 171 | rerender(); | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Recomputes the HTML head then renders the document. | |
| 176 | */ | |
| 177 | private void rerender() { | |
| 178 | mHead = generateHead(); | |
| 179 | render( mDocument.toString() ); | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * Attaches the HTML head prefix and HTML tail suffix to the given HTML | |
| 184 | * string. | |
| 185 | * | |
| 186 | * @param html The HTML to adorn with opening and closing tags. | |
| 187 | * @return A complete HTML document, ready for rendering. | |
| 188 | */ | |
| 189 | private String decorate( final String html ) { | |
| 190 | mDocument.setLength( 0 ); | |
| 191 | mDocument.append( html ); | |
| 192 | ||
| 193 | // Head and tail must be separate from document due to re-rendering. | |
| 194 | return mHead + mDocument + HTML_TAIL; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Called when settings are changed that affect the HTML document preamble. | |
| 199 | * This is a minor performance optimization to avoid generating the head | |
| 200 | * each time that the document itself changes. | |
| 201 | * | |
| 202 | * @return A new doctype and HTML {@code head} element. | |
| 203 | */ | |
| 204 | private String generateHead() { | |
| 205 | final var locale = getLocale(); | |
| 206 | final var base = getBaseUri(); | |
| 207 | final var custom = getCustomStylesheetUrl(); | |
| 208 | ||
| 209 | // Point sizes are converted to pixels because of a rendering bug. | |
| 210 | return format( | |
| 211 | HTML_HEAD, | |
| 212 | locale.getLanguage(), | |
| 213 | toStylesheetString( HTML_STYLE_PREVIEW ), | |
| 214 | toStylesheetString( toUrl( locale ) ), | |
| 215 | toStylesheetString( custom ), | |
| 216 | getFontFamily(), | |
| 217 | toPixels( getFontSize() ), | |
| 218 | base.isBlank() ? "" : format( HTML_BASE, base ) | |
| 219 | ); | |
| 220 | } | |
| 221 | ||
| 222 | /** | |
| 223 | * Clears the preview pane by rendering an empty string. | |
| 224 | */ | |
| 225 | public void clear() { | |
| 226 | render( "" ); | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Sets the base URI to the containing directory the file being edited. | |
| 231 | * | |
| 232 | * @param path The path to the file being edited. | |
| 233 | */ | |
| 234 | public void setBaseUri( final Path path ) { | |
| 235 | final var parent = path.getParent(); | |
| 236 | mBaseUriPath = parent == null ? "" : parent.toUri().toString(); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Scrolls to the closest element matching the given identifier without | |
| 241 | * waiting for the document to be ready. | |
| 242 | * | |
| 243 | * @param id Scroll the preview pane to this unique paragraph identifier. | |
| 244 | */ | |
| 245 | public void scrollTo( final String id ) { | |
| 246 | if( !mScrollLocked ) { | |
| 247 | mPreview.scrollTo( id, mScrollPane ); | |
| 248 | mScrollPane.repaint(); | |
| 249 | } | |
| 250 | } | |
| 251 | ||
| 252 | private String getBaseUri() { | |
| 253 | return mBaseUriPath; | |
| 254 | } | |
| 255 | ||
| 256 | private JScrollPane getScrollPane() { | |
| 257 | return mScrollPane; | |
| 258 | } | |
| 259 | ||
| 260 | public JScrollBar getVerticalScrollBar() { | |
| 261 | return getScrollPane().getVerticalScrollBar(); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Returns the ISO 639 alpha-2 or alpha-3 language code followed by a hyphen | |
| 266 | * followed by the ISO 15924 alpha-4 script code, followed by an ISO 3166 | |
| 267 | * alpha-2 country code or UN M.49 numeric-3 area code. For example, this | |
| 268 | * could return "en-Latn-CA" for Canadian English written in the Latin | |
| 269 | * character set. | |
| 270 | * | |
| 271 | * @return Unique identifier for language and country. | |
| 272 | */ | |
| 273 | private static URL toUrl( final Locale locale ) { | |
| 274 | return toUrl( | |
| 275 | String.format( | |
| 276 | sSettings.getSetting( STYLESHEET_PREVIEW_LOCALE, "" ), | |
| 277 | locale.getLanguage(), | |
| 278 | locale.getScript(), | |
| 279 | locale.getCountry() | |
| 280 | ) | |
| 281 | ); | |
| 282 | } | |
| 283 | ||
| 284 | private static URL toUrl( final String path ) { | |
| 285 | return HtmlPreview.class.getResource( path ); | |
| 286 | } | |
| 287 | ||
| 288 | private Locale getLocale() { | |
| 289 | return localeProperty().toLocale(); | |
| 290 | } | |
| 291 | ||
| 292 | private LocaleProperty localeProperty() { | |
| 293 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 294 | } | |
| 295 | ||
| 296 | private String getFontFamily() { | |
| 297 | return fontFamilyProperty().get(); | |
| 298 | } | |
| 299 | ||
| 300 | private StringProperty fontFamilyProperty() { | |
| 301 | return mWorkspace.stringProperty( KEY_UI_FONT_PREVIEW_NAME ); | |
| 302 | } | |
| 303 | ||
| 304 | private double getFontSize() { | |
| 305 | return fontSizeProperty().get(); | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Returns the font size in points. | |
| 310 | * | |
| 311 | * @return The user-defined font size (in pt). | |
| 312 | */ | |
| 313 | private DoubleProperty fontSizeProperty() { | |
| 314 | return mWorkspace.doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ); | |
| 315 | } | |
| 316 | ||
| 317 | private String getLockText( final boolean locked ) { | |
| 318 | return Character.toString( (locked ? LOCK : UNLOCK_ALT).getChar() ); | |
| 319 | } | |
| 320 | ||
| 321 | private URL getCustomStylesheetUrl() { | |
| 322 | try { | |
| 323 | return mWorkspace.getFile( KEY_UI_PREVIEW_STYLESHEET ).toURI().toURL(); | |
| 324 | } catch( final Exception ex ) { | |
| 325 | clue( ex ); | |
| 326 | return null; | |
| 327 | } | |
| 328 | } | |
| 329 | ||
| 330 | /** | |
| 331 | * Maps keyboard events to scrollbar commands so that users may control | |
| 332 | * the {@link HtmlPreview} panel using the keyboard. | |
| 333 | * | |
| 334 | * @param map The map to update with keyboard events. | |
| 335 | */ | |
| 336 | private void addKeyboardEvents( final InputMap map ) { | |
| 337 | map.put( getKeyStroke( VK_DOWN, 0 ), "positiveUnitIncrement" ); | |
| 338 | map.put( getKeyStroke( VK_UP, 0 ), "negativeUnitIncrement" ); | |
| 339 | map.put( getKeyStroke( VK_PAGE_DOWN, 0 ), "positiveBlockIncrement" ); | |
| 340 | map.put( getKeyStroke( VK_PAGE_UP, 0 ), "negativeBlockIncrement" ); | |
| 341 | map.put( getKeyStroke( VK_HOME, 0 ), "minScroll" ); | |
| 342 | map.put( getKeyStroke( VK_END, 0 ), "maxScroll" ); | |
| 343 | } | |
| 344 | ||
| 345 | @Override | |
| 346 | public void componentResized( final ComponentEvent e ) { | |
| 347 | if( mWorkspace.getBoolean( KEY_IMAGES_RESIZE ) ) { | |
| 350 | 348 | mPreview.clearCache(); |
| 351 | 349 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors; | |
| 3 | ||
| 4 | import java.util.Map; | |
| 5 | import java.util.function.Function; | |
| 6 | ||
| 7 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; | |
| 8 | ||
| 9 | /** | |
| 10 | * Processes interpolated string definitions in the document and inserts | |
| 11 | * their values into the post-processed text. The default variable syntax is | |
| 12 | * {@code $variable$}. | |
| 13 | */ | |
| 14 | public class DefinitionProcessor | |
| 15 | extends ExecutorProcessor<String> implements Function<String, String> { | |
| 16 | ||
| 17 | private final ProcessorContext mContext; | |
| 18 | ||
| 19 | /** | |
| 20 | * Constructs a processor capable of interpolating string definitions. | |
| 21 | * | |
| 22 | * @param successor Subsequent link in the processing chain. | |
| 23 | * @param context Contains resolved definitions map. | |
| 24 | */ | |
| 25 | public DefinitionProcessor( | |
| 26 | final Processor<String> successor, | |
| 27 | final ProcessorContext context ) { | |
| 28 | super( successor ); | |
| 29 | mContext = context; | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Processes the given text document by replacing variables with their values. | |
| 34 | * | |
| 35 | * @param text The document text that includes variables that should be | |
| 36 | * replaced with values when rendered as HTML. | |
| 37 | * @return The text with all variables replaced. | |
| 38 | */ | |
| 39 | @Override | |
| 40 | public String apply( final String text ) { | |
| 41 | return replace( text, getDefinitions() ); | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Returns the map to use for variable substitution. | |
| 46 | * | |
| 47 | * @return A map of variable names to values. | |
| 48 | */ | |
| 49 | protected Map<String, String> getDefinitions() { | |
| 50 | return mContext.getResolvedMap(); | |
| 51 | } | |
| 52 | } | |
| 53 | 1 |
| 15 | 15 | * There is only one preview panel. |
| 16 | 16 | */ |
| 17 | private static HtmlPreview sHtmlPreviewPane; | |
| 17 | private static HtmlPreview sHtmlPreview; | |
| 18 | 18 | |
| 19 | 19 | /** |
| 20 | 20 | * Constructs the end of a processing chain. |
| 21 | 21 | * |
| 22 | * @param htmlPreviewPane The pane to update with the post-processed document. | |
| 22 | * @param htmlPreview The pane to update with the post-processed document. | |
| 23 | 23 | */ |
| 24 | public HtmlPreviewProcessor( final HtmlPreview htmlPreviewPane ) { | |
| 25 | sHtmlPreviewPane = htmlPreviewPane; | |
| 24 | public HtmlPreviewProcessor( final HtmlPreview htmlPreview ) { | |
| 25 | sHtmlPreview = htmlPreview; | |
| 26 | 26 | } |
| 27 | 27 | |
| ... | ||
| 37 | 37 | assert html != null; |
| 38 | 38 | |
| 39 | sHtmlPreviewPane.render( html ); | |
| 39 | sHtmlPreview.render( html ); | |
| 40 | 40 | return html; |
| 41 | 41 | } |
| 9 | 9 | import static com.keenwrite.events.StatusEvent.clue; |
| 10 | 10 | import static com.keenwrite.io.MediaType.TEXT_XML; |
| 11 | import static com.keenwrite.preferences.AppKeys.*; | |
| 12 | import static com.keenwrite.typesetting.Typesetter.Mutator; | |
| 11 | 13 | import static java.nio.file.Files.deleteIfExists; |
| 12 | 14 | import static java.nio.file.Files.writeString; |
| ... | ||
| 35 | 37 | try { |
| 36 | 38 | clue( "Main.status.typeset.create" ); |
| 39 | final var workspace = mContext.getWorkspace(); | |
| 37 | 40 | final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE ); |
| 38 | final var pathInput = writeString( document, xhtml ); | |
| 39 | final var pathOutput = mContext.getOutputPath(); | |
| 40 | final var typesetter = new Typesetter( mContext.getWorkspace() ); | |
| 41 | final var typesetter = Typesetter | |
| 42 | .builder() | |
| 43 | .with( Mutator::setInputPath, | |
| 44 | writeString( document, xhtml ) ) | |
| 45 | .with( Mutator::setOutputPath, | |
| 46 | mContext.getOutputPath() ) | |
| 47 | .with( Mutator::setThemePath, | |
| 48 | workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ) ) | |
| 49 | .with( Mutator::setThemeName, | |
| 50 | workspace.getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ) ) | |
| 51 | .with( Mutator::setAutoclean, | |
| 52 | workspace.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 53 | .build(); | |
| 41 | 54 | |
| 42 | typesetter.typeset( pathInput, pathOutput ); | |
| 55 | typesetter.typeset(); | |
| 43 | 56 | |
| 44 | 57 | // Smote the temporary file after typesetting the document. |
| 5 | 5 | import com.keenwrite.ExportFormat; |
| 6 | 6 | import com.keenwrite.constants.Constants; |
| 7 | import com.keenwrite.editors.TextDefinition; | |
| 8 | 7 | import com.keenwrite.io.FileType; |
| 9 | 8 | import com.keenwrite.preferences.Workspace; |
| 10 | import com.keenwrite.preview.HtmlPreview; | |
| 9 | import com.keenwrite.sigils.SigilKeyOperator; | |
| 11 | 10 | import com.keenwrite.util.GenericBuilder; |
| 12 | import javafx.beans.property.ObjectProperty; | |
| 11 | import com.keenwrite.collections.InterpolatingMap; | |
| 13 | 12 | |
| 14 | 13 | import java.io.File; |
| 15 | 14 | import java.nio.file.Path; |
| 16 | 15 | import java.util.Map; |
| 16 | import java.util.concurrent.Callable; | |
| 17 | import java.util.function.Supplier; | |
| 17 | 18 | |
| 18 | 19 | import static com.keenwrite.AbstractFileFactory.lookup; |
| ... | ||
| 27 | 28 | |
| 28 | 29 | /** |
| 29 | * Creates a new context for use by the {@link ProcessorFactory} when | |
| 30 | * instantiating new {@link Processor} instances. Although all the | |
| 31 | * parameters are required, not all {@link Processor} instances will use | |
| 32 | * all parameters. | |
| 30 | * Responsible for populating the instance variables required by the | |
| 31 | * context. | |
| 33 | 32 | */ |
| 34 | private ProcessorContext( final Mutator mutator ) { | |
| 35 | assert mutator != null; | |
| 36 | ||
| 37 | mMutator = mutator; | |
| 38 | } | |
| 39 | ||
| 40 | 33 | public static class Mutator { |
| 41 | private HtmlPreview mHtmlPreview; | |
| 42 | private ObjectProperty<TextDefinition> mTextDefinition; | |
| 43 | 34 | private Path mInputPath; |
| 44 | 35 | private Path mOutputPath; |
| 45 | private Caret mCaret; | |
| 46 | 36 | private ExportFormat mExportFormat; |
| 37 | private Supplier<Map<String, String>> mDefinitions; | |
| 38 | private Supplier<Caret> mCaret; | |
| 47 | 39 | private Workspace mWorkspace; |
| 48 | ||
| 49 | public void setHtmlPreview( final HtmlPreview htmlPreview ) { | |
| 50 | mHtmlPreview = htmlPreview; | |
| 51 | } | |
| 52 | ||
| 53 | public void setTextDefinition( | |
| 54 | final ObjectProperty<TextDefinition> textDefinition ) { | |
| 55 | mTextDefinition = textDefinition; | |
| 56 | } | |
| 57 | 40 | |
| 58 | 41 | public void setInputPath( final Path inputPath ) { |
| ... | ||
| 72 | 55 | } |
| 73 | 56 | |
| 74 | public void setCaret( final Caret caret ) { | |
| 57 | /** | |
| 58 | * Sets the list of fully interpolated key-value pairs to use when | |
| 59 | * substituting variable names back into the document as variable values. | |
| 60 | * This uses a {@link Callable} reference so that GUI and command-line | |
| 61 | * usage can insert their respective behaviours. That is, this method | |
| 62 | * prevents coupling the GUI to the CLI. | |
| 63 | * | |
| 64 | * @param definitions Defines how to retrieve the definitions. | |
| 65 | */ | |
| 66 | public void setDefinitions( | |
| 67 | final Supplier<Map<String, String>> definitions ) { | |
| 68 | mDefinitions = definitions; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Sets the source for deriving the {@link Caret}. Typically, this is | |
| 73 | * the text editor that has focus. | |
| 74 | * | |
| 75 | * @param caret The source for the currently active caret. | |
| 76 | */ | |
| 77 | public void setCaret( final Supplier<Caret> caret ) { | |
| 75 | 78 | mCaret = caret; |
| 76 | 79 | } |
| ... | ||
| 86 | 89 | |
| 87 | 90 | public static GenericBuilder<Mutator, ProcessorContext> builder() { |
| 88 | return GenericBuilder.of( | |
| 89 | Mutator::new, | |
| 90 | ProcessorContext::new | |
| 91 | ); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * @param inputPath Path to the document to process. | |
| 96 | * @param outputPath Fully qualified filename to use when exporting. | |
| 97 | * @param format Indicate configuration options for export format. | |
| 98 | * @param preview Where to display the final (HTML) output. | |
| 99 | * @param textDefinition Source for fully expanded interpolated strings. | |
| 100 | * @param workspace Persistent user preferences settings. | |
| 101 | * @param caret Location of the caret in the edited document, | |
| 102 | * which is used to synchronize the scrollbars. | |
| 103 | * @return A context that may be used for processing documents. | |
| 104 | */ | |
| 105 | public static ProcessorContext create( | |
| 106 | final Path inputPath, | |
| 107 | final Path outputPath, | |
| 108 | final ExportFormat format, | |
| 109 | final HtmlPreview preview, | |
| 110 | final ObjectProperty<TextDefinition> textDefinition, | |
| 111 | final Workspace workspace, | |
| 112 | final Caret caret ) { | |
| 113 | return builder() | |
| 114 | .with( Mutator::setInputPath, inputPath ) | |
| 115 | .with( Mutator::setOutputPath, outputPath ) | |
| 116 | .with( Mutator::setExportFormat, format ) | |
| 117 | .with( Mutator::setHtmlPreview, preview ) | |
| 118 | .with( Mutator::setTextDefinition, textDefinition ) | |
| 119 | .with( Mutator::setWorkspace, workspace ) | |
| 120 | .with( Mutator::setCaret, caret ) | |
| 121 | .build(); | |
| 91 | return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | |
| 122 | 92 | } |
| 123 | 93 | |
| ... | ||
| 137 | 107 | |
| 138 | 108 | /** |
| 139 | * @param inputPath Path to the document to process. | |
| 140 | * @param outputPath Fully qualified filename to use when exporting. | |
| 141 | * @param format Indicate configuration options for export format. | |
| 142 | * @return A context that may be used for processing documents. | |
| 109 | * Creates a new context for use by the {@link ProcessorFactory} when | |
| 110 | * instantiating new {@link Processor} instances. Although all the | |
| 111 | * parameters are required, not all {@link Processor} instances will use | |
| 112 | * all parameters. | |
| 143 | 113 | */ |
| 144 | public static ProcessorContext create( | |
| 145 | final Path inputPath, final Path outputPath, final ExportFormat format ) { | |
| 146 | return builder() | |
| 147 | .with( Mutator::setInputPath, inputPath ) | |
| 148 | .with( Mutator::setOutputPath, outputPath ) | |
| 149 | .with( Mutator::setExportFormat, format ) | |
| 150 | .build(); | |
| 151 | } | |
| 114 | private ProcessorContext( final Mutator mutator ) { | |
| 115 | assert mutator != null; | |
| 152 | 116 | |
| 153 | public boolean isExportFormat( final ExportFormat format ) { | |
| 154 | return mMutator.mExportFormat == format; | |
| 117 | mMutator = mutator; | |
| 155 | 118 | } |
| 156 | 119 | |
| 157 | HtmlPreview getPreview() { | |
| 158 | return mMutator.mHtmlPreview; | |
| 120 | /** | |
| 121 | * Returns the variable map of definitions, without interpolation. | |
| 122 | * | |
| 123 | * @return A map to help dereference variables. | |
| 124 | */ | |
| 125 | public Map<String, String> getDefinitions() { | |
| 126 | return mMutator.mDefinitions.get(); | |
| 159 | 127 | } |
| 160 | 128 | |
| 161 | 129 | /** |
| 162 | * Returns the variable map of interpolated definitions. | |
| 130 | * Returns the variable map of definitions, with interpolation. | |
| 163 | 131 | * |
| 164 | 132 | * @return A map to help dereference variables. |
| 165 | 133 | */ |
| 166 | Map<String, String> getResolvedMap() { | |
| 167 | return mMutator.mTextDefinition.get().getDefinitions(); | |
| 134 | public InterpolatingMap getInterpolatedDefinitions() { | |
| 135 | final var map = new InterpolatingMap( | |
| 136 | createDefinitionSigilOperator(), getDefinitions() | |
| 137 | ); | |
| 138 | ||
| 139 | map.interpolate(); | |
| 140 | ||
| 141 | return map; | |
| 168 | 142 | } |
| 169 | 143 | |
| ... | ||
| 187 | 161 | * @return Caret position in the document. |
| 188 | 162 | */ |
| 189 | public Caret getCaret() { | |
| 163 | public Supplier<Caret> getCaret() { | |
| 190 | 164 | return mMutator.mCaret; |
| 191 | 165 | } |
| ... | ||
| 204 | 178 | */ |
| 205 | 179 | public Path getBaseDir() { |
| 206 | final var path = getDocumentPath().toAbsolutePath().getParent(); | |
| 180 | final var path = getInputPath().toAbsolutePath().getParent(); | |
| 207 | 181 | return path == null ? DEFAULT_DIRECTORY : path; |
| 208 | 182 | } |
| 209 | 183 | |
| 210 | public Path getDocumentPath() { | |
| 184 | public Path getInputPath() { | |
| 211 | 185 | return mMutator.mInputPath; |
| 212 | 186 | } |
| 213 | 187 | |
| 214 | 188 | FileType getFileType() { |
| 215 | return lookup( getDocumentPath() ); | |
| 189 | return lookup( getInputPath() ); | |
| 216 | 190 | } |
| 217 | 191 | |
| 218 | 192 | public Workspace getWorkspace() { |
| 219 | 193 | return mMutator.mWorkspace; |
| 194 | } | |
| 195 | ||
| 196 | public SigilKeyOperator createSigilOperator() { | |
| 197 | return getWorkspace().createSigilOperator( getInputPath() ); | |
| 198 | } | |
| 199 | ||
| 200 | public SigilKeyOperator createDefinitionSigilOperator() { | |
| 201 | return getWorkspace().createDefinitionKeyOperator(); | |
| 220 | 202 | } |
| 221 | 203 | } |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.AbstractFileFactory; |
| 5 | import com.keenwrite.preview.HtmlPreview; | |
| 6 | 5 | import com.keenwrite.processors.markdown.MarkdownProcessor; |
| 7 | 6 | |
| 8 | import static com.keenwrite.ExportFormat.*; | |
| 9 | 7 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 10 | 8 | |
| 11 | 9 | /** |
| 12 | 10 | * Responsible for creating processors capable of parsing, transforming, |
| 13 | 11 | * interpolating, and rendering known file types. |
| 14 | 12 | */ |
| 15 | 13 | public final class ProcessorFactory extends AbstractFileFactory { |
| 16 | 14 | |
| 17 | private final ProcessorContext mContext; | |
| 15 | private ProcessorFactory() { | |
| 16 | } | |
| 17 | ||
| 18 | public static Processor<String> createProcessors( | |
| 19 | final ProcessorContext context ) { | |
| 20 | return createProcessors( context, null ); | |
| 21 | } | |
| 18 | 22 | |
| 19 | 23 | /** |
| 20 | * Constructs a factory with the ability to create processors that can perform | |
| 21 | * text and caret processing to generate a final preview. | |
| 24 | * Creates a new {@link Processor} chain suitable for parsing and rendering | |
| 25 | * the file opened at the given tab. | |
| 22 | 26 | * |
| 23 | * @param context Parameters needed to construct various processors. | |
| 27 | * @param context The tab containing a text editor, path, and caret position. | |
| 28 | * @return A processor that can render the given tab's text. | |
| 24 | 29 | */ |
| 25 | private ProcessorFactory( final ProcessorContext context ) { | |
| 26 | mContext = context; | |
| 30 | public static Processor<String> createProcessors( | |
| 31 | final ProcessorContext context, final Processor<String> preview ) { | |
| 32 | return ProcessorFactory.createProcessor( context, preview ); | |
| 27 | 33 | } |
| 28 | ||
| 29 | private Processor<String> createProcessor() { | |
| 30 | final var context = getProcessorContext(); | |
| 31 | 34 | |
| 35 | /** | |
| 36 | * Constructs processors that chain various processing operations on a | |
| 37 | * document to generate a transformed version of the source document. | |
| 38 | * | |
| 39 | * @param context Parameters needed to construct various processors. | |
| 40 | * @param preview The processor to use when no export format is specified. | |
| 41 | */ | |
| 42 | private static Processor<String> createProcessor( | |
| 43 | final ProcessorContext context, final Processor<String> preview ) { | |
| 32 | 44 | // If the content is not to be exported, then the successor processor |
| 33 | 45 | // is one that parses Markdown into HTML and passes the string to the |
| ... | ||
| 40 | 52 | // to SVG. Without conversion would require client-side rendering of |
| 41 | 53 | // math (such as using the JavaScript-based KaTeX engine). |
| 42 | final var successor = context.isExportFormat( NONE ) | |
| 43 | ? createHtmlPreviewProcessor( context ) | |
| 44 | : context.isExportFormat( XHTML_TEX ) | |
| 45 | ? createXhtmlProcessor( context ) | |
| 46 | : context.isExportFormat( APPLICATION_PDF ) | |
| 47 | ? createPdfProcessor( context ) | |
| 48 | : createIdentityProcessor( context ); | |
| 54 | final var successor = switch( context.getExportFormat() ) { | |
| 55 | case NONE -> preview; | |
| 56 | case XHTML_TEX -> createXhtmlProcessor( context ); | |
| 57 | case APPLICATION_PDF -> createPdfProcessor( context ); | |
| 58 | default -> createIdentityProcessor( context ); | |
| 59 | }; | |
| 49 | 60 | |
| 50 | 61 | final var processor = switch( context.getFileType() ) { |
| 51 | case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor ); | |
| 62 | case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor, context ); | |
| 52 | 63 | default -> createPreformattedProcessor( successor ); |
| 53 | 64 | }; |
| 54 | 65 | |
| 55 | 66 | return new ExecutorProcessor<>( processor ); |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Creates a new {@link Processor} chain suitable for parsing and rendering | |
| 60 | * the file opened at the given tab. | |
| 61 | * | |
| 62 | * @param context The tab containing a text editor, path, and caret position. | |
| 63 | * @return A processor that can render the given tab's text. | |
| 64 | */ | |
| 65 | public static Processor<String> createProcessors( | |
| 66 | final ProcessorContext context ) { | |
| 67 | return new ProcessorFactory( context ).createProcessor(); | |
| 68 | 67 | } |
| 69 | 68 | |
| 70 | 69 | /** |
| 71 | 70 | * Instantiates a new {@link Processor} that has no successor and returns |
| 72 | 71 | * the string it was given without modification. |
| 73 | 72 | * |
| 74 | 73 | * @return An instance of {@link Processor} that performs no processing. |
| 75 | 74 | */ |
| 76 | 75 | @SuppressWarnings( "unused" ) |
| 77 | private Processor<String> createIdentityProcessor( | |
| 76 | private static Processor<String> createIdentityProcessor( | |
| 78 | 77 | final ProcessorContext ignored ) { |
| 79 | 78 | return IDENTITY; |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Instantiates a new {@link Processor} that passes an incoming HTML | |
| 84 | * string to a user interface widget that can render HTML as a web page. | |
| 85 | * | |
| 86 | * @return An instance of {@link Processor} that forwards HTML for display. | |
| 87 | */ | |
| 88 | @SuppressWarnings( "unused" ) | |
| 89 | private Processor<String> createHtmlPreviewProcessor( | |
| 90 | final ProcessorContext ignored ) { | |
| 91 | return new HtmlPreviewProcessor( getPreviewPane() ); | |
| 92 | 79 | } |
| 93 | ||
| 94 | 80 | /** |
| 95 | 81 | * Instantiates a {@link Processor} responsible for parsing Markdown and |
| 96 | 82 | * definitions. |
| 97 | 83 | * |
| 98 | 84 | * @return A chain of {@link Processor}s for processing Markdown and |
| 99 | 85 | * definitions. |
| 100 | 86 | */ |
| 101 | private Processor<String> createMarkdownProcessor( | |
| 102 | final Processor<String> successor ) { | |
| 103 | final var dp = createDefinitionProcessor( successor ); | |
| 104 | return MarkdownProcessor.create( dp, getProcessorContext() ); | |
| 87 | private static Processor<String> createMarkdownProcessor( | |
| 88 | final Processor<String> successor, | |
| 89 | final ProcessorContext context ) { | |
| 90 | final var dp = createDefinitionProcessor( successor, context ); | |
| 91 | return MarkdownProcessor.create( dp, context ); | |
| 105 | 92 | } |
| 106 | 93 | |
| 107 | private Processor<String> createDefinitionProcessor( | |
| 108 | final Processor<String> successor ) { | |
| 109 | return new DefinitionProcessor( successor, getProcessorContext() ); | |
| 94 | private static Processor<String> createDefinitionProcessor( | |
| 95 | final Processor<String> successor, | |
| 96 | final ProcessorContext context ) { | |
| 97 | return new VariableProcessor( successor, context ); | |
| 110 | 98 | } |
| 111 | 99 | |
| ... | ||
| 118 | 106 | * @return An instance of {@link Processor} that completes an HTML document. |
| 119 | 107 | */ |
| 120 | private Processor<String> createXhtmlProcessor( | |
| 108 | private static Processor<String> createXhtmlProcessor( | |
| 121 | 109 | final ProcessorContext context ) { |
| 122 | 110 | return createXhtmlProcessor( IDENTITY, context ); |
| 123 | 111 | } |
| 124 | 112 | |
| 125 | private Processor<String> createXhtmlProcessor( | |
| 113 | private static Processor<String> createXhtmlProcessor( | |
| 126 | 114 | final Processor<String> successor, final ProcessorContext context ) { |
| 127 | 115 | return new XhtmlProcessor( successor, context ); |
| 128 | 116 | } |
| 129 | 117 | |
| 130 | private Processor<String> createPdfProcessor( | |
| 118 | private static Processor<String> createPdfProcessor( | |
| 131 | 119 | final ProcessorContext context ) { |
| 132 | 120 | final var pdfp = new PdfProcessor( context ); |
| 133 | 121 | return createXhtmlProcessor( pdfp, context ); |
| 134 | 122 | } |
| 135 | 123 | |
| 136 | private Processor<String> createPreformattedProcessor( | |
| 124 | private static Processor<String> createPreformattedProcessor( | |
| 137 | 125 | final Processor<String> successor ) { |
| 138 | 126 | return new PreformattedProcessor( successor ); |
| 139 | } | |
| 140 | ||
| 141 | private ProcessorContext getProcessorContext() { | |
| 142 | return mContext; | |
| 143 | } | |
| 144 | ||
| 145 | private HtmlPreview getPreviewPane() { | |
| 146 | return getProcessorContext().getPreview(); | |
| 147 | 127 | } |
| 148 | 128 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors; | |
| 3 | ||
| 4 | import java.util.HashMap; | |
| 5 | import java.util.Map; | |
| 6 | import java.util.function.Function; | |
| 7 | import java.util.function.UnaryOperator; | |
| 8 | ||
| 9 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; | |
| 10 | ||
| 11 | /** | |
| 12 | * Processes interpolated string definitions in the document and inserts | |
| 13 | * their values into the post-processed text. The default variable syntax is | |
| 14 | * <pre>{{variable}}</pre> (a.k.a., moustache syntax). | |
| 15 | */ | |
| 16 | public class VariableProcessor | |
| 17 | extends ExecutorProcessor<String> implements Function<String, String> { | |
| 18 | ||
| 19 | private final ProcessorContext mContext; | |
| 20 | private final UnaryOperator<String> mSigilOperator; | |
| 21 | ||
| 22 | /** | |
| 23 | * Constructs a processor capable of interpolating string definitions. | |
| 24 | * | |
| 25 | * @param successor Subsequent link in the processing chain. | |
| 26 | * @param context Contains resolved definitions map. | |
| 27 | */ | |
| 28 | public VariableProcessor( | |
| 29 | final Processor<String> successor, | |
| 30 | final ProcessorContext context ) { | |
| 31 | super( successor ); | |
| 32 | ||
| 33 | mSigilOperator = createKeyOperator( context ); | |
| 34 | mContext = context; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Subclasses may change the type of operation performed on keys, such as | |
| 39 | * wrapping key names in sigils. | |
| 40 | * | |
| 41 | * @param context Provides the name of the file being edited. | |
| 42 | * @return An operator for transforming key names. | |
| 43 | */ | |
| 44 | protected UnaryOperator<String> createKeyOperator( | |
| 45 | final ProcessorContext context ) { | |
| 46 | return context.createSigilOperator(); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Returns the map to use for variable substitution. | |
| 51 | * | |
| 52 | * @return A map of variable names to values, with keys wrapped in sigils. | |
| 53 | */ | |
| 54 | protected Map<String, String> getDefinitions() { | |
| 55 | return entoken( mContext.getInterpolatedDefinitions() ); | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Subclasses may override this method to change how keys are wrapped | |
| 60 | * in sigils. | |
| 61 | * | |
| 62 | * @param key The key to enwrap. | |
| 63 | * @return The wrapped key. | |
| 64 | */ | |
| 65 | protected String processKey( final String key ) { | |
| 66 | return mSigilOperator.apply( key ); | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Subclasses may override this method to modify values prior to use. This | |
| 71 | * can be used, for example, to escape values prior to evaluating by a | |
| 72 | * scripting engine. | |
| 73 | * | |
| 74 | * @param value The value to process. | |
| 75 | * @return The processed value. | |
| 76 | */ | |
| 77 | protected String processValue( final String value ) { | |
| 78 | return value; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Processes the given text document by replacing variables with their values. | |
| 83 | * | |
| 84 | * @param text The document text that includes variables that should be | |
| 85 | * replaced with values when rendered as HTML. | |
| 86 | * @return The text with all variables replaced. | |
| 87 | */ | |
| 88 | @Override | |
| 89 | public String apply( final String text ) { | |
| 90 | return replace( text, getDefinitions() ); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Converts the given map from regular variables to processor-specific | |
| 95 | * variables. | |
| 96 | * | |
| 97 | * @param map Map of variable names to values. | |
| 98 | * @return Map of variables with the keys and values subjected to | |
| 99 | * post-processing. | |
| 100 | */ | |
| 101 | protected Map<String, String> entoken( final Map<String, String> map ) { | |
| 102 | final var result = new HashMap<String, String>( map.size() ); | |
| 103 | ||
| 104 | map.forEach( ( k, v ) -> result.put( processKey( k ), processValue( v ) ) ); | |
| 105 | ||
| 106 | return result; | |
| 107 | } | |
| 108 | ||
| 109 | protected ProcessorContext getContext() { | |
| 110 | return mContext; | |
| 111 | } | |
| 112 | } | |
| 1 | 113 |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.dom.DocumentParser; |
| 5 | import com.keenwrite.preferences.Key; | |
| 6 | 5 | import com.keenwrite.preferences.Workspace; |
| 7 | 6 | import com.keenwrite.ui.heuristics.WordCounter; |
| 8 | 7 | import com.whitemagicsoftware.keenquotes.Contractions; |
| 9 | 8 | import com.whitemagicsoftware.keenquotes.Converter; |
| 10 | import javafx.beans.property.StringProperty; | |
| 9 | import javafx.beans.property.ListProperty; | |
| 11 | 10 | import org.w3c.dom.Document; |
| 12 | 11 | |
| 13 | 12 | import java.io.FileNotFoundException; |
| 14 | 13 | import java.nio.file.Path; |
| 14 | import java.util.LinkedHashMap; | |
| 15 | 15 | import java.util.List; |
| 16 | 16 | import java.util.Locale; |
| 17 | 17 | import java.util.Map; |
| 18 | import java.util.Map.Entry; | |
| 18 | 19 | import java.util.regex.Pattern; |
| 19 | 20 | |
| 20 | 21 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; |
| 21 | import static com.keenwrite.dom.DocumentParser.*; | |
| 22 | import static com.keenwrite.dom.DocumentParser.createMeta; | |
| 23 | import static com.keenwrite.dom.DocumentParser.visit; | |
| 22 | 24 | import static com.keenwrite.events.StatusEvent.clue; |
| 23 | 25 | import static com.keenwrite.io.HttpFacade.httpGet; |
| 24 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 25 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; | |
| 26 | import static com.keenwrite.preferences.AppKeys.*; | |
| 26 | 27 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 27 | 28 | import static com.whitemagicsoftware.keenquotes.Converter.CHARS; |
| ... | ||
| 47 | 48 | |
| 48 | 49 | private final ProcessorContext mContext; |
| 50 | ||
| 51 | /** | |
| 52 | * Adorns the given document with {@code html}, {@code head}, and | |
| 53 | * {@code body} elements. | |
| 54 | * | |
| 55 | * @param html The document to decorate. | |
| 56 | * @return A document with a typical HTML structure. | |
| 57 | */ | |
| 58 | private static String decorate( final String html ) { | |
| 59 | return | |
| 60 | "<html><head><title> </title><meta charset='utf8'/></head><body>" | |
| 61 | + html | |
| 62 | + "</body></html>"; | |
| 63 | } | |
| 49 | 64 | |
| 50 | 65 | public XhtmlProcessor( |
| ... | ||
| 71 | 86 | setMetaData( doc ); |
| 72 | 87 | |
| 73 | walk( doc, "//img", node -> { | |
| 88 | visit( doc, "//img", node -> { | |
| 74 | 89 | try { |
| 75 | 90 | final var attrs = node.getAttributes(); |
| ... | ||
| 105 | 120 | */ |
| 106 | 121 | private void setMetaData( final Document doc ) { |
| 107 | final var metadata = createMetaData( doc ); | |
| 108 | ||
| 109 | walk( doc, "/html/head", node -> | |
| 122 | final var metadata = createMetaDataMap( doc ); | |
| 123 | visit( doc, "/html/head", node -> | |
| 110 | 124 | metadata.entrySet() |
| 111 | 125 | .forEach( entry -> node.appendChild( createMeta( doc, entry ) ) ) |
| 112 | 126 | ); |
| 113 | walk( doc, "/html/head/title", node -> node.setTextContent( title() ) ); | |
| 127 | ||
| 128 | final var title = metadata.get( "title" ); | |
| 129 | if( title != null ) { | |
| 130 | visit( doc, "/html/head/title", node -> node.setTextContent( title ) ); | |
| 131 | } | |
| 114 | 132 | } |
| 115 | 133 | |
| 116 | 134 | /** |
| 117 | 135 | * Generates document metadata, including word count. |
| 118 | 136 | * |
| 119 | 137 | * @param doc The document containing the text to tally. |
| 120 | 138 | * @return A map of metadata key/value pairs. |
| 121 | 139 | */ |
| 122 | private Map<String, String> createMetaData( final Document doc ) { | |
| 123 | return Map.of( | |
| 124 | "author", author(), | |
| 125 | "byline", byLine(), | |
| 126 | "address", address(), | |
| 127 | "phone", phone(), | |
| 128 | "email", email(), | |
| 129 | "count", wordCount( doc ), | |
| 130 | "keywords", keywords(), | |
| 131 | "copyright", copyright(), | |
| 132 | "date", date() | |
| 140 | private Map<String, String> createMetaDataMap( final Document doc ) { | |
| 141 | final Map<String, String> result = new LinkedHashMap<>(); | |
| 142 | final var metadata = getMetaData(); | |
| 143 | final var map = mContext.getInterpolatedDefinitions(); | |
| 144 | ||
| 145 | metadata.forEach( entry -> result.put( | |
| 146 | entry.getKey(), map.interpolate( entry.getValue() ) ) | |
| 133 | 147 | ); |
| 148 | result.put( "count", wordCount( doc ) ); | |
| 149 | ||
| 150 | return result; | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * The metadata is in list form because the user interface for entering the | |
| 155 | * key-value pairs is a table, which requires a generic {@link List} rather | |
| 156 | * than a generic {@link Map}. | |
| 157 | * | |
| 158 | * @return The document metadata. | |
| 159 | */ | |
| 160 | private ListProperty<Entry<String, String>> getMetaData() { | |
| 161 | return getWorkspace().listsProperty( KEY_DOC_META ); | |
| 134 | 162 | } |
| 135 | 163 | |
| ... | ||
| 198 | 226 | |
| 199 | 227 | private String getImagePath() { |
| 200 | return getWorkspace().toFile( KEY_IMAGES_DIR ).toString(); | |
| 228 | return getWorkspace().getFile( KEY_IMAGES_DIR ).toString(); | |
| 201 | 229 | } |
| 202 | 230 | |
| 203 | 231 | private String getImageOrder() { |
| 204 | return getWorkspace().toString( KEY_IMAGES_ORDER ); | |
| 232 | return getWorkspace().getString( KEY_IMAGES_ORDER ); | |
| 205 | 233 | } |
| 206 | 234 | |
| ... | ||
| 220 | 248 | |
| 221 | 249 | private Locale locale() {return getWorkspace().getLocale();} |
| 222 | ||
| 223 | private String title() { | |
| 224 | return resolve( KEY_DOC_TITLE ); | |
| 225 | } | |
| 226 | ||
| 227 | private String author() { | |
| 228 | return resolve( KEY_DOC_AUTHOR ); | |
| 229 | } | |
| 230 | ||
| 231 | private String byLine() { | |
| 232 | return resolve( KEY_DOC_BYLINE ); | |
| 233 | } | |
| 234 | ||
| 235 | private String address() { | |
| 236 | return resolve( KEY_DOC_ADDRESS ).replaceAll( "\n", "\\\\\\break{}" ); | |
| 237 | } | |
| 238 | ||
| 239 | private String phone() { | |
| 240 | return resolve( KEY_DOC_PHONE ); | |
| 241 | } | |
| 242 | ||
| 243 | private String email() { | |
| 244 | return resolve( KEY_DOC_EMAIL ); | |
| 245 | } | |
| 246 | 250 | |
| 247 | 251 | private String wordCount( final Document doc ) { |
| 248 | 252 | final var sb = new StringBuilder( 65536 * 10 ); |
| 249 | 253 | |
| 250 | walk( | |
| 254 | visit( | |
| 251 | 255 | doc, |
| 252 | 256 | "//*[normalize-space( text() ) != '']", |
| 253 | 257 | node -> sb.append( node.getTextContent() ) |
| 254 | 258 | ); |
| 255 | 259 | |
| 256 | 260 | return valueOf( WordCounter.create( locale() ).count( sb.toString() ) ); |
| 257 | } | |
| 258 | ||
| 259 | private String keywords() { | |
| 260 | return resolve( KEY_DOC_KEYWORDS ); | |
| 261 | } | |
| 262 | ||
| 263 | private String copyright() { | |
| 264 | return resolve( KEY_DOC_COPYRIGHT ); | |
| 265 | } | |
| 266 | ||
| 267 | private String date() { | |
| 268 | return resolve( KEY_DOC_DATE ); | |
| 269 | 261 | } |
| 270 | 262 | |
| 271 | 263 | /** |
| 272 | 264 | * Answers whether straight quotation marks should be curled. |
| 273 | 265 | * |
| 274 | 266 | * @return {@code false} to prevent curling straight quotes. |
| 275 | 267 | */ |
| 276 | 268 | private boolean curl() { |
| 277 | return getWorkspace().toBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ); | |
| 278 | } | |
| 279 | ||
| 280 | private String resolve( final Key key ) { | |
| 281 | return replace( asString( key ), mContext.getResolvedMap() ); | |
| 282 | } | |
| 283 | ||
| 284 | private String asString( final Key key ) { | |
| 285 | return stringProperty( key ).get(); | |
| 286 | } | |
| 287 | ||
| 288 | private StringProperty stringProperty( final Key key ) { | |
| 289 | return getWorkspace().stringProperty( key ); | |
| 269 | return getWorkspace().getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ); | |
| 290 | 270 | } |
| 291 | 271 | |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.io.MediaType; |
| 5 | import com.keenwrite.processors.DefinitionProcessor; | |
| 5 | import com.keenwrite.processors.VariableProcessor; | |
| 6 | 6 | import com.keenwrite.processors.Processor; |
| 7 | 7 | import com.keenwrite.processors.ProcessorContext; |
| ... | ||
| 53 | 53 | @Override |
| 54 | 54 | List<Extension> createExtensions( final ProcessorContext context ) { |
| 55 | final var editorFile = context.getDocumentPath(); | |
| 55 | final var editorFile = context.getInputPath(); | |
| 56 | 56 | final var mediaType = MediaType.valueFrom( editorFile ); |
| 57 | 57 | final Processor<String> processor; |
| ... | ||
| 64 | 64 | } |
| 65 | 65 | else { |
| 66 | processor = new DefinitionProcessor( IDENTITY, context ); | |
| 66 | processor = new VariableProcessor( IDENTITY, context ); | |
| 67 | 67 | } |
| 68 | 68 | |
| 16 | 16 | import org.jetbrains.annotations.NotNull; |
| 17 | 17 | |
| 18 | import java.util.function.Supplier; | |
| 19 | ||
| 18 | 20 | import static com.keenwrite.constants.Constants.CARET_ID; |
| 19 | 21 | import static com.keenwrite.processors.markdown.extensions.EmptyNode.EMPTY_NODE; |
| ... | ||
| 26 | 28 | public class CaretExtension extends HtmlRendererAdapter { |
| 27 | 29 | |
| 28 | private final Caret mCaret; | |
| 30 | private final Supplier<Caret> mCaret; | |
| 29 | 31 | |
| 30 | 32 | private CaretExtension( final ProcessorContext context ) { |
| ... | ||
| 48 | 50 | */ |
| 49 | 51 | public static class IdAttributeProvider implements AttributeProvider { |
| 50 | private final Caret mCaret; | |
| 52 | private final Supplier<Caret> mCaret; | |
| 51 | 53 | private boolean mAdded; |
| 52 | 54 | |
| 53 | public IdAttributeProvider( final Caret caret ) { | |
| 55 | public IdAttributeProvider( final Supplier<Caret> caret ) { | |
| 54 | 56 | mCaret = caret; |
| 55 | 57 | } |
| 56 | 58 | |
| 57 | private static AttributeProviderFactory createFactory( final Caret caret ) { | |
| 59 | private static AttributeProviderFactory createFactory( | |
| 60 | final Supplier<Caret> caret ) { | |
| 58 | 61 | return new IndependentAttributeProviderFactory() { |
| 59 | 62 | @Override |
| ... | ||
| 73 | 76 | return; |
| 74 | 77 | } |
| 78 | ||
| 79 | final var caret = mCaret.get(); | |
| 75 | 80 | |
| 76 | 81 | // If a table block has been earmarked with an empty node, it means |
| ... | ||
| 92 | 97 | } |
| 93 | 98 | |
| 94 | final var outside = mCaret.isAfterText() ? 1 : 0; | |
| 99 | final var outside = caret.isAfterText() ? 1 : 0; | |
| 95 | 100 | final var began = curr.getStartOffset(); |
| 96 | 101 | final var ended = curr.getEndOffset() + outside; |
| 97 | 102 | final var prev = curr.getPrevious(); |
| 98 | 103 | |
| 99 | 104 | // If the caret is within the bounds of the current node or the |
| 100 | 105 | // caret is within the bounds of the end of the previous node and |
| 101 | 106 | // the start of the current node, then mark the current node with |
| 102 | 107 | // a caret indicator. |
| 103 | if( mCaret.isBetweenText( began, ended ) || | |
| 104 | prev != null && mCaret.isBetweenText( prev.getEndOffset(), began ) ) { | |
| 108 | if( caret.isBetweenText( began, ended ) || | |
| 109 | prev != null && caret.isBetweenText( prev.getEndOffset(), began ) ) { | |
| 105 | 110 | // This line empowers synchronizing the text editor with the preview. |
| 106 | 111 | attributes.addValue( AttributeImpl.of( "id", CARET_ID ) ); |
| 18 | 18 | import static com.keenwrite.ExportFormat.NONE; |
| 19 | 19 | import static com.keenwrite.events.StatusEvent.clue; |
| 20 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_DIR; | |
| 21 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_ORDER; | |
| 20 | import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_DIR; | |
| 21 | import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_ORDER; | |
| 22 | 22 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 23 | 23 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| ... | ||
| 148 | 148 | |
| 149 | 149 | private Path getUserImagesDir() { |
| 150 | return mWorkspace.toFile( KEY_IMAGES_DIR ).toPath(); | |
| 150 | return mWorkspace.getFile( KEY_IMAGES_DIR ).toPath(); | |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | 153 | private Iterable<String> getImageExtensions() { |
| 154 | return on( ' ' ).split( mWorkspace.toString( KEY_IMAGES_ORDER ) ); | |
| 154 | return on( ' ' ).split( mWorkspace.getString( KEY_IMAGES_ORDER ) ); | |
| 155 | 155 | } |
| 156 | 156 | |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | 5 | import com.keenwrite.preview.DiagramUrlGenerator; |
| 6 | import com.keenwrite.processors.DefinitionProcessor; | |
| 6 | import com.keenwrite.processors.VariableProcessor; | |
| 7 | 7 | import com.keenwrite.processors.Processor; |
| 8 | 8 | import com.keenwrite.processors.ProcessorContext; |
| ... | ||
| 23 | 23 | import java.util.Set; |
| 24 | 24 | |
| 25 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER; | |
| 25 | import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_SERVER; | |
| 26 | 26 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 27 | 27 | import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT; |
| ... | ||
| 51 | 51 | * to generate SVG files of text diagrams. |
| 52 | 52 | * <p> |
| 53 | * Internally, this creates a {@link DefinitionProcessor} to substitute | |
| 53 | * Internally, this creates a {@link VariableProcessor} to substitute | |
| 54 | 54 | * variable definitions. This is necessary because the order of processors |
| 55 | * matters. If the {@link DefinitionProcessor} comes before an instance of | |
| 55 | * matters. If the {@link VariableProcessor} comes before an instance of | |
| 56 | 56 | * {@link MarkdownProcessor}, for example, then the caret position in the |
| 57 | 57 | * preview pane will not align with the caret position in the editor |
| ... | ||
| 107 | 107 | final var content = node.getContentChars().normalizeEOL(); |
| 108 | 108 | final var text = mProcessor.apply( content ); |
| 109 | final var server = mWorkspace.toString( KEY_IMAGES_SERVER ); | |
| 109 | final var server = mWorkspace.getString( KEY_IMAGES_SERVER ); | |
| 110 | 110 | final var source = DiagramUrlGenerator.toUrl( server, type, text ); |
| 111 | 111 | final var link = context.resolveLink( LINK, source, false ); |
| 7 | 7 | import com.keenwrite.processors.r.InlineRProcessor; |
| 8 | 8 | import com.keenwrite.processors.r.RProcessor; |
| 9 | import com.keenwrite.sigils.RSigilOperator; | |
| 10 | 9 | import com.vladsch.flexmark.ast.Paragraph; |
| 11 | import com.vladsch.flexmark.ast.Text; | |
| 12 | 10 | import com.vladsch.flexmark.parser.InlineParserExtensionFactory; |
| 13 | import com.vladsch.flexmark.parser.InlineParserFactory; | |
| 14 | 11 | import com.vladsch.flexmark.parser.delimiter.DelimiterProcessor; |
| 15 | 12 | import com.vladsch.flexmark.parser.internal.InlineParserImpl; |
| ... | ||
| 23 | 20 | |
| 24 | 21 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; |
| 25 | import static com.keenwrite.processors.markdown.extensions.EmptyNode.EMPTY_NODE; | |
| 26 | 22 | import static com.vladsch.flexmark.parser.Parser.Builder; |
| 27 | 23 | import static com.vladsch.flexmark.parser.Parser.ParserExtension; |
| 28 | 24 | |
| 29 | 25 | /** |
| 30 | 26 | * Responsible for processing inline R statements (denoted using the |
| 31 | * {@link RSigilOperator#PREFIX}) to prevent them from being converted to | |
| 27 | * {@link InlineRProcessor#PREFIX}) to prevent them from being converted to | |
| 32 | 28 | * HTML {@code <code>} elements and stop them from interfering with TeX |
| 33 | 29 | * statements. Note that TeX statements are processed using a Markdown |
| 34 | 30 | * extension, rather than an implementation of {@link Processor}. For this |
| 35 | 31 | * reason, some pre-conversion is necessary. |
| 36 | 32 | */ |
| 37 | 33 | public final class RExtension implements ParserExtension { |
| 38 | private final InlineParserFactory INLINE_FACTORY = InlineParser::new; | |
| 39 | 34 | private final RProcessor mProcessor; |
| 40 | 35 | private final BaseMarkdownProcessor mMarkdownProcessor; |
| ... | ||
| 57 | 52 | @Override |
| 58 | 53 | public void extend( final Builder builder ) { |
| 59 | builder.customInlineParserFactory( INLINE_FACTORY ); | |
| 54 | builder.customInlineParserFactory( InlineParser::new ); | |
| 60 | 55 | } |
| 61 | 56 | |
| 62 | 57 | @Override |
| 63 | public void parserOptions( final MutableDataHolder options ) { | |
| 64 | } | |
| 58 | public void parserOptions( final MutableDataHolder options ) {} | |
| 65 | 59 | |
| 66 | 60 | /** |
| ... | ||
| 84 | 78 | final LinkRefProcessorData referenceLinkProcessors, |
| 85 | 79 | final List<InlineParserExtensionFactory> inlineParserExtensions ) { |
| 86 | super( options, | |
| 87 | specialCharacters, | |
| 88 | delimiterCharacters, | |
| 89 | delimiterProcessors, | |
| 90 | referenceLinkProcessors, | |
| 91 | inlineParserExtensions ); | |
| 80 | super( | |
| 81 | options, | |
| 82 | specialCharacters, | |
| 83 | delimiterCharacters, | |
| 84 | delimiterProcessors, | |
| 85 | referenceLinkProcessors, | |
| 86 | inlineParserExtensions | |
| 87 | ); | |
| 92 | 88 | mProcessor.init(); |
| 93 | 89 | } |
| 94 | 90 | |
| 95 | 91 | /** |
| 96 | 92 | * The superclass handles a number backtick parsing edge cases; this method |
| 97 | 93 | * changes the behaviour to retain R code snippets, identified by |
| 98 | * {@link RSigilOperator#PREFIX}, so that subsequent processing can | |
| 94 | * {@link InlineRProcessor#PREFIX}, so that subsequent processing can | |
| 99 | 95 | * invoke R. If other languages are added, the {@link InlineParser} will |
| 100 | 96 | * have to be rewritten to identify more than merely R. |
| ... | ||
| 114 | 110 | final var code = codeNode.getChars().toString(); |
| 115 | 111 | |
| 116 | if( code.startsWith( RSigilOperator.PREFIX ) ) { | |
| 112 | if( code.startsWith( InlineRProcessor.PREFIX ) ) { | |
| 117 | 113 | codeNode.unlink(); |
| 114 | ||
| 118 | 115 | final var rText = mProcessor.apply( code ); |
| 119 | 116 | var node = mMarkdownProcessor.toNode( rText ); |
| 120 | ||
| 121 | if( node.getFirstChild() instanceof Paragraph ) { | |
| 122 | node = new Text( rText ); | |
| 123 | } | |
| 124 | else { | |
| 125 | node = node.getFirstChild(); | |
| 126 | 117 | |
| 127 | if( node != null ) { | |
| 128 | // Mark the node as being generated code, such as text returned | |
| 129 | // from an R function. | |
| 130 | node.appendChild( EMPTY_NODE ); | |
| 131 | } | |
| 118 | if( node.getFirstChild() instanceof Paragraph paragraph ) { | |
| 119 | node = paragraph.getFirstChild(); | |
| 132 | 120 | } |
| 133 | 121 | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.markdown.extensions.r; | |
| 3 | ||
| 4 | import com.keenwrite.processors.ExecutorProcessor; | |
| 5 | import com.keenwrite.processors.r.InlineRProcessor; | |
| 6 | import com.keenwrite.processors.markdown.MarkdownProcessor; | |
| 7 | import com.keenwrite.processors.markdown.extensions.tex.TeXExtension; | |
| 8 | import com.vladsch.flexmark.ast.Paragraph; | |
| 9 | import com.vladsch.flexmark.ast.Text; | |
| 10 | import com.vladsch.flexmark.html.HtmlRenderer; | |
| 11 | import com.vladsch.flexmark.parser.Parser; | |
| 12 | import com.vladsch.flexmark.util.ast.IParse; | |
| 13 | import com.vladsch.flexmark.util.ast.IRender; | |
| 14 | ||
| 15 | /** | |
| 16 | * Responsible for parsing the output from an R eval statement. This class | |
| 17 | * is used to avoid an circular dependency whereby the {@link InlineRProcessor} | |
| 18 | * must treat the output from an R function call as Markdown, which would | |
| 19 | * otherwise require a {@link MarkdownProcessor} instance; however, the | |
| 20 | * {@link MarkdownProcessor} class gives precedence to its extensions, which | |
| 21 | * means the {@link TeXExtension} will be executed <em>before</em> the | |
| 22 | * {@link InlineRProcessor}, thereby being exposed to backticks in a TeX | |
| 23 | * macro---a syntax error. To break the cycle, the {@link InlineRProcessor} | |
| 24 | * uses this class instead of {@link MarkdownProcessor}. | |
| 25 | */ | |
| 26 | public class ROutputProcessor extends ExecutorProcessor<String> { | |
| 27 | private final IParse mParser = Parser.builder().build(); | |
| 28 | private final IRender mRenderer = HtmlRenderer.builder().build(); | |
| 29 | ||
| 30 | @Override | |
| 31 | public String apply( final String markdown ) { | |
| 32 | var node = mParser.parse( markdown ).getFirstChild(); | |
| 33 | ||
| 34 | if( node == null ) { | |
| 35 | node = new Text(); | |
| 36 | } | |
| 37 | else if( node.isOrDescendantOfType( Paragraph.class ) ) { | |
| 38 | node = new Text( node.getChars() ); | |
| 39 | } | |
| 40 | ||
| 41 | // Trimming prevents displaced commas and unwanted newlines. | |
| 42 | return mRenderer.render( node ).trim(); | |
| 43 | } | |
| 44 | } | |
| 45 | 1 |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.processors.r; | |
| 3 | ||
| 4 | import com.keenwrite.collections.BoundedCache; | |
| 5 | ||
| 6 | import javax.script.ScriptEngine; | |
| 7 | import javax.script.ScriptEngineManager; | |
| 8 | import java.util.Map; | |
| 9 | ||
| 10 | import static com.keenwrite.Messages.get; | |
| 11 | import static com.keenwrite.events.StatusEvent.clue; | |
| 12 | import static java.lang.Math.min; | |
| 13 | ||
| 14 | /** | |
| 15 | * Responsible for executing R statements, which can also update the engine's | |
| 16 | * state. | |
| 17 | */ | |
| 18 | public final class Engine { | |
| 19 | /** | |
| 20 | * Inline R expressions that have already been evaluated. | |
| 21 | */ | |
| 22 | private static final Map<String, String> sCache = | |
| 23 | new BoundedCache<>( 512 ); | |
| 24 | ||
| 25 | /** | |
| 26 | * Engine for evaluating R expressions. | |
| 27 | */ | |
| 28 | private static final ScriptEngine sEngine = | |
| 29 | (new ScriptEngineManager()).getEngineByName( "Renjin" ); | |
| 30 | ||
| 31 | /** | |
| 32 | * Empties the cache. | |
| 33 | */ | |
| 34 | public static void clear() { | |
| 35 | sCache.clear(); | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Look up an R expression from the cache then return the resulting object. | |
| 40 | * If the R expression hasn't been cached, it'll first be evaluated. | |
| 41 | * | |
| 42 | * @param r R expression to evaluate. | |
| 43 | * @return The object resulting from the evaluation. | |
| 44 | */ | |
| 45 | public static String eval( final String r ) { | |
| 46 | return sCache.computeIfAbsent( r, __ -> evaluate( r ) ); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Returns the result of an R expression as an object converted to string. | |
| 51 | * | |
| 52 | * @param r R expression to evaluate. | |
| 53 | * @return The object resulting from the evaluation. | |
| 54 | */ | |
| 55 | private static String evaluate( final String r ) { | |
| 56 | try { | |
| 57 | return sEngine.eval( r ).toString(); | |
| 58 | } catch( final Exception ex ) { | |
| 59 | final var expr = r.substring( 0, min( r.length(), 50 ) ); | |
| 60 | clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex ); | |
| 61 | throw new IllegalArgumentException( r ); | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 1 | 65 |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | import com.keenwrite.processors.DefinitionProcessor; | |
| 6 | 5 | import com.keenwrite.processors.Processor; |
| 7 | 6 | import com.keenwrite.processors.ProcessorContext; |
| 8 | import com.keenwrite.processors.markdown.extensions.r.ROutputProcessor; | |
| 9 | import com.keenwrite.util.BoundedCache; | |
| 7 | import com.keenwrite.processors.VariableProcessor; | |
| 8 | import com.keenwrite.sigils.RKeyOperator; | |
| 10 | 9 | import javafx.beans.property.Property; |
| 10 | import org.jetbrains.annotations.NotNull; | |
| 11 | 11 | |
| 12 | import javax.script.ScriptEngine; | |
| 13 | import javax.script.ScriptEngineManager; | |
| 14 | 12 | import java.io.File; |
| 15 | 13 | import java.nio.file.Path; |
| 16 | import java.util.Map; | |
| 14 | import java.util.HashMap; | |
| 17 | 15 | import java.util.concurrent.atomic.AtomicBoolean; |
| 18 | 16 | |
| 19 | 17 | import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR; |
| 20 | import static com.keenwrite.Messages.get; | |
| 21 | 18 | import static com.keenwrite.events.StatusEvent.clue; |
| 22 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 19 | import static com.keenwrite.preferences.AppKeys.KEY_R_DIR; | |
| 20 | import static com.keenwrite.preferences.AppKeys.KEY_R_SCRIPT; | |
| 21 | import static com.keenwrite.processors.r.RVariableProcessor.escape; | |
| 23 | 22 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| 24 | import static com.keenwrite.sigils.RSigilOperator.PREFIX; | |
| 25 | import static com.keenwrite.sigils.RSigilOperator.SUFFIX; | |
| 26 | import static java.lang.Math.max; | |
| 27 | 23 | import static java.lang.Math.min; |
| 28 | import static java.lang.String.format; | |
| 29 | 24 | |
| 30 | 25 | /** |
| 31 | 26 | * Transforms a document containing R statements into Markdown. |
| 32 | 27 | */ |
| 33 | public final class InlineRProcessor extends DefinitionProcessor { | |
| 34 | private final Processor<String> mPostProcessor = new ROutputProcessor(); | |
| 35 | ||
| 36 | /** | |
| 37 | * Where to put document inline evaluated R expressions, constrained to | |
| 38 | * avoid running out of memory. | |
| 39 | */ | |
| 40 | private final Map<String, String> mEvalCache = | |
| 41 | new BoundedCache<>( 512 ); | |
| 42 | ||
| 43 | private static final ScriptEngine ENGINE = | |
| 44 | (new ScriptEngineManager()).getEngineByName( "Renjin" ); | |
| 28 | public final class InlineRProcessor extends VariableProcessor { | |
| 29 | public static final String PREFIX = "`r#"; | |
| 30 | public static final char SUFFIX = '`'; | |
| 45 | 31 | |
| 46 | 32 | private static final int PREFIX_LENGTH = PREFIX.length(); |
| 47 | 33 | |
| 48 | private final AtomicBoolean mDirty = new AtomicBoolean( false ); | |
| 34 | /** | |
| 35 | * Set to {@code true} when the R bootstrap script is loaded successfully. | |
| 36 | */ | |
| 37 | private final AtomicBoolean mReady = new AtomicBoolean(); | |
| 49 | 38 | |
| 50 | private final Workspace mWorkspace; | |
| 39 | private final RKeyOperator mOperator = new RKeyOperator(); | |
| 51 | 40 | |
| 52 | 41 | /** |
| ... | ||
| 60 | 49 | final ProcessorContext context ) { |
| 61 | 50 | super( successor, context ); |
| 62 | ||
| 63 | mWorkspace = context.getWorkspace(); | |
| 64 | ||
| 65 | bootstrapScriptProperty().addListener( | |
| 66 | ( __, oldScript, newScript ) -> setDirty( true ) ); | |
| 67 | workingDirectoryProperty().addListener( | |
| 68 | ( __, oldScript, newScript ) -> setDirty( true ) ); | |
| 69 | ||
| 70 | // TODO: Watch the "R" property keys in the workspace, directly. | |
| 71 | ||
| 72 | // If the user saves the preferences, make sure that any R-related settings | |
| 73 | // changes are applied. | |
| 74 | // getWorkspace().addSaveEventHandler( ( handler ) -> { | |
| 75 | // if( isDirty() ) { | |
| 76 | // init(); | |
| 77 | // setDirty( false ); | |
| 78 | // } | |
| 79 | // } ); | |
| 80 | ||
| 81 | init(); | |
| 82 | 51 | } |
| 83 | 52 | |
| 84 | 53 | /** |
| 85 | * Initialises the R code so that R can find imported libraries. Note that | |
| 54 | * Initializes the R code so that R can find imported libraries. Note that | |
| 86 | 55 | * any existing R functionality will not be overwritten if this method is |
| 87 | 56 | * called multiple times. |
| 88 | * | |
| 89 | * @return {@code true} if initialization completed and all variables were | |
| 90 | * replaced; {@code false} if any variables remain. | |
| 57 | * <p> | |
| 58 | * If the R code to bootstrap contained variables, and they were all updated | |
| 59 | * successfully, this will update the internal ready flag to {@code true}. | |
| 91 | 60 | */ |
| 92 | public boolean init() { | |
| 61 | public void init() { | |
| 93 | 62 | final var bootstrap = getBootstrapScript(); |
| 94 | 63 | |
| 95 | 64 | if( !bootstrap.isBlank() ) { |
| 96 | 65 | final var wd = getWorkingDirectory(); |
| 97 | 66 | final var dir = wd.toString().replace( '\\', '/' ); |
| 98 | final var map = getDefinitions(); | |
| 99 | final var defBegan = mWorkspace.toString( KEY_DEF_DELIM_BEGAN ); | |
| 100 | final var defEnded = mWorkspace.toString( KEY_DEF_DELIM_ENDED ); | |
| 101 | ||
| 102 | map.put( defBegan + "application.r.working.directory" + defEnded, dir ); | |
| 103 | ||
| 104 | final var replaced = replace( bootstrap, map ); | |
| 105 | final var bIndex = replaced.indexOf( defBegan ); | |
| 106 | ||
| 107 | // If there's a delimiter in the replaced text it means not all variables | |
| 108 | // are bound, which is an error. | |
| 109 | if( bIndex >= 0 ) { | |
| 110 | var eIndex = replaced.indexOf( defEnded ); | |
| 111 | eIndex = (eIndex == -1) ? replaced.length() - 1 : max( bIndex, eIndex ); | |
| 67 | final var definitions = getContext().getDefinitions(); | |
| 68 | final var map = new HashMap<String, String>( definitions.size() + 1 ); | |
| 112 | 69 | |
| 113 | final var def = replaced.substring( | |
| 114 | bIndex + defBegan.length(), eIndex ); | |
| 115 | clue( "Main.status.error.bootstrap.eval", | |
| 116 | format( "%s%s%s", defBegan, def, defEnded ) ); | |
| 70 | definitions.forEach( | |
| 71 | ( k, v ) -> map.put( mOperator.apply( k ), escape( v ) ) | |
| 72 | ); | |
| 73 | map.put( | |
| 74 | mOperator.apply( "application.r.working.directory" ), | |
| 75 | escape( dir ) | |
| 76 | ); | |
| 117 | 77 | |
| 118 | return false; | |
| 119 | } | |
| 120 | else { | |
| 121 | eval( replaced ); | |
| 78 | try { | |
| 79 | Engine.eval( replace( bootstrap, map ) ); | |
| 80 | mReady.set( true ); | |
| 81 | } catch( final Exception ignored ) { | |
| 82 | // A problem with the bootstrap script is likely caused by variables | |
| 83 | // not being loaded. This implies that the R processor is being invoked | |
| 84 | // too soon. | |
| 122 | 85 | } |
| 123 | 86 | } |
| 124 | ||
| 125 | return true; | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Empties the cache. | |
| 130 | */ | |
| 131 | public void clear() { | |
| 132 | mEvalCache.clear(); | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Sets the dirty flag to indicate that the bootstrap script or working | |
| 137 | * directory has been modified. Upon saving the preferences, if this flag | |
| 138 | * is true, then {@link #init()} will be called to reload the R environment. | |
| 139 | * | |
| 140 | * @param dirty Set to true to reload changes upon closing preferences. | |
| 141 | */ | |
| 142 | private void setDirty( final boolean dirty ) { | |
| 143 | mDirty.set( dirty ); | |
| 144 | 87 | } |
| 145 | 88 | |
| 146 | 89 | /** |
| 147 | * Answers whether R-related settings have been modified. | |
| 90 | * Answers whether R has been initialized without failures. | |
| 148 | 91 | * |
| 149 | * @return {@code true} when the settings have changed. | |
| 92 | * @return {@code true} the R engine is ready to process inline R statements. | |
| 150 | 93 | */ |
| 151 | private boolean isDirty() { | |
| 152 | return mDirty.get(); | |
| 94 | public boolean isReady() { | |
| 95 | return mReady.get(); | |
| 153 | 96 | } |
| 154 | 97 | |
| ... | ||
| 163 | 106 | */ |
| 164 | 107 | @Override |
| 165 | public String apply( final String text ) { | |
| 108 | public @NotNull String apply( final String text ) { | |
| 166 | 109 | final int length = text.length(); |
| 167 | 110 | |
| ... | ||
| 191 | 134 | try { |
| 192 | 135 | // Append the string representation of the result into the text. |
| 193 | sb.append( evalCached( r ) ); | |
| 136 | sb.append( Engine.eval( r ) ); | |
| 194 | 137 | } catch( final Exception ex ) { |
| 195 | 138 | // Inform the user that there was a problem. |
| ... | ||
| 211 | 154 | // Copy from the previous index to the end of the string. |
| 212 | 155 | return sb.append( text.substring( min( prevIndex, length ) ) ).toString(); |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * Look up an R expression from the cache then return the resulting object. | |
| 217 | * If the R expression hasn't been cached, it'll first be evaluated. | |
| 218 | * | |
| 219 | * @param r The expression to evaluate. | |
| 220 | * @return The object resulting from the evaluation. | |
| 221 | */ | |
| 222 | private String evalCached( final String r ) { | |
| 223 | return mEvalCache.computeIfAbsent( r, __ -> evalHtml( r ) ); | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * Converts the given string to HTML, trimming new lines, and inlining | |
| 228 | * the text if it is a paragraph. Otherwise, the resulting HTML is most likely | |
| 229 | * complex (e.g., a Markdown table) and should be rendered as its HTML | |
| 230 | * equivalent. | |
| 231 | * | |
| 232 | * @param r The R expression to evaluate then convert to HTML. | |
| 233 | * @return The result from the R expression as an HTML element. | |
| 234 | */ | |
| 235 | private String evalHtml( final String r ) { | |
| 236 | return mPostProcessor.apply( eval( r ) ); | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * Evaluate an R expression and return the resulting object. | |
| 241 | * | |
| 242 | * @param r The expression to evaluate. | |
| 243 | * @return The object resulting from the evaluation. | |
| 244 | */ | |
| 245 | private String eval( final String r ) { | |
| 246 | try { | |
| 247 | return ENGINE.eval( r ).toString(); | |
| 248 | } catch( final Exception ex ) { | |
| 249 | final var expr = r.substring( 0, min( r.length(), 50 ) ); | |
| 250 | clue( get( "Main.status.error.r", expr, ex.getMessage() ), ex ); | |
| 251 | return ""; | |
| 252 | } | |
| 253 | 156 | } |
| 254 | 157 | |
| ... | ||
| 281 | 184 | |
| 282 | 185 | private Workspace getWorkspace() { |
| 283 | return mWorkspace; | |
| 186 | return getContext().getWorkspace(); | |
| 284 | 187 | } |
| 285 | 188 | } |
| 18 | 18 | private final InlineRProcessor mInlineRProcessor; |
| 19 | 19 | |
| 20 | private boolean mReady; | |
| 21 | ||
| 22 | 20 | public RProcessor( final ProcessorContext context ) { |
| 23 | 21 | final var irp = new InlineRProcessor( IDENTITY, context ); |
| 24 | 22 | final var rvp = new RVariableProcessor( irp, context ); |
| 23 | ||
| 25 | 24 | mProcessor = new ExecutorProcessor<>( rvp ); |
| 26 | 25 | mInlineRProcessor = irp; |
| 27 | } | |
| 28 | ||
| 29 | public void init() { | |
| 30 | mReady = mInlineRProcessor.init(); | |
| 31 | 26 | } |
| 32 | 27 | |
| 33 | 28 | public String apply( final String text ) { |
| 29 | if( !mInlineRProcessor.isReady() ) { | |
| 30 | mInlineRProcessor.init(); | |
| 31 | } | |
| 32 | ||
| 34 | 33 | return mProcessor.apply( text ); |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Called when the {@link InlineRProcessor} is instantiated, which triggers | |
| 38 | * a re-evaluation of all R expressions in the document. Without this, when | |
| 39 | * the document is first viewed, no R expressions are evaluated until the | |
| 40 | * user interacts with the document. | |
| 41 | */ | |
| 42 | public void init() { | |
| 43 | mInlineRProcessor.init(); | |
| 35 | 44 | } |
| 36 | 45 | |
| 37 | 46 | public boolean isReady() { |
| 38 | return mReady; | |
| 47 | return mInlineRProcessor.isReady(); | |
| 39 | 48 | } |
| 40 | 49 | } |
| 2 | 2 | package com.keenwrite.processors.r; |
| 3 | 3 | |
| 4 | import com.keenwrite.preferences.Workspace; | |
| 5 | import com.keenwrite.processors.DefinitionProcessor; | |
| 6 | 4 | import com.keenwrite.processors.ProcessorContext; |
| 7 | import com.keenwrite.sigils.RSigilOperator; | |
| 8 | import com.keenwrite.sigils.SigilOperator; | |
| 9 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 10 | ||
| 11 | import java.util.HashMap; | |
| 12 | import java.util.Map; | |
| 5 | import com.keenwrite.processors.VariableProcessor; | |
| 6 | import com.keenwrite.sigils.RKeyOperator; | |
| 13 | 7 | |
| 14 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 8 | import java.util.function.UnaryOperator; | |
| 15 | 9 | |
| 16 | 10 | /** |
| 17 | 11 | * Converts the keys of the resolved map from default form to R form, then |
| 18 | 12 | * performs a substitution on the text. The default R variable syntax is |
| 19 | * {@code v$tree$leaf}. | |
| 13 | * <pre>v$tree$leaf</pre>. | |
| 20 | 14 | */ |
| 21 | public final class RVariableProcessor extends DefinitionProcessor { | |
| 22 | ||
| 23 | private final SigilOperator mSigilOperator; | |
| 24 | ||
| 15 | public final class RVariableProcessor extends VariableProcessor { | |
| 25 | 16 | public RVariableProcessor( |
| 26 | 17 | final InlineRProcessor irp, final ProcessorContext context ) { |
| 27 | 18 | super( irp, context ); |
| 28 | mSigilOperator = createSigilOperator( context.getWorkspace() ); | |
| 29 | 19 | } |
| 30 | 20 | |
| 31 | /** | |
| 32 | * Returns the R-based version of the interpolated variable definitions. | |
| 33 | * | |
| 34 | * @return Variable names transmogrified from the default syntax to R syntax. | |
| 35 | */ | |
| 36 | 21 | @Override |
| 37 | protected Map<String, String> getDefinitions() { | |
| 38 | return entoken( super.getDefinitions() ); | |
| 22 | protected UnaryOperator<String> createKeyOperator( | |
| 23 | final ProcessorContext context ) { | |
| 24 | return new RKeyOperator(); | |
| 39 | 25 | } |
| 40 | ||
| 41 | /** | |
| 42 | * Converts the given map from regular variables to R variables. | |
| 43 | * | |
| 44 | * @param map Map of variable names to values. | |
| 45 | * @return Map of R variables. | |
| 46 | */ | |
| 47 | private Map<String, String> entoken( final Map<String, String> map ) { | |
| 48 | final var rMap = new HashMap<String, String>( map.size() ); | |
| 49 | 26 | |
| 50 | for( final var entry : map.entrySet() ) { | |
| 51 | final var key = entry.getKey(); | |
| 52 | rMap.put( mSigilOperator.entoken( key ), escape( map.get( key ) ) ); | |
| 53 | } | |
| 27 | @Override | |
| 28 | protected String processValue( final String value ) { | |
| 29 | assert value != null; | |
| 54 | 30 | |
| 55 | return rMap; | |
| 31 | return escape( value ); | |
| 56 | 32 | } |
| 57 | 33 | |
| 58 | private String escape( final String value ) { | |
| 34 | /** | |
| 35 | * In R, single quotes and double quotes are interchangeable. Using single | |
| 36 | * quotes is simpler to code. | |
| 37 | * | |
| 38 | * @param value The text to convert into a valid quoted R string. | |
| 39 | * @return The quoted value with embedded quotes escaped as necessary. | |
| 40 | */ | |
| 41 | public static String escape( final String value ) { | |
| 59 | 42 | return '\'' + escape( value, '\'', "\\'" ) + '\''; |
| 60 | 43 | } |
| ... | ||
| 69 | 52 | */ |
| 70 | 53 | @SuppressWarnings( "SameParameterValue" ) |
| 71 | private String escape( | |
| 54 | private static String escape( | |
| 72 | 55 | final String haystack, final char needle, final String thread ) { |
| 56 | assert haystack != null; | |
| 57 | assert thread != null; | |
| 58 | ||
| 73 | 59 | int end = haystack.indexOf( needle ); |
| 74 | 60 | |
| 75 | 61 | if( end < 0 ) { |
| 76 | 62 | return haystack; |
| 77 | 63 | } |
| 78 | 64 | |
| 79 | final int length = haystack.length(); | |
| 80 | 65 | int start = 0; |
| 81 | 66 | |
| 82 | 67 | // Replace up to 32 occurrences before reallocating the internal buffer. |
| 83 | final var sb = new StringBuilder( length + 32 ); | |
| 68 | final var sb = new StringBuilder( haystack.length() + 32 ); | |
| 84 | 69 | |
| 85 | 70 | while( end >= 0 ) { |
| 86 | 71 | sb.append( haystack, start, end ).append( thread ); |
| 87 | 72 | start = end + 1; |
| 88 | 73 | end = haystack.indexOf( needle, start ); |
| 89 | 74 | } |
| 90 | 75 | |
| 91 | 76 | return sb.append( haystack.substring( start ) ).toString(); |
| 92 | } | |
| 93 | ||
| 94 | private SigilOperator createSigilOperator( final Workspace workspace ) { | |
| 95 | final var tokens = workspace.toSigils( | |
| 96 | KEY_R_DELIM_BEGAN, KEY_R_DELIM_ENDED ); | |
| 97 | final var antecedent = createDefinitionOperator( workspace ); | |
| 98 | return new RSigilOperator( tokens, antecedent ); | |
| 99 | } | |
| 100 | ||
| 101 | private SigilOperator createDefinitionOperator( final Workspace workspace ) { | |
| 102 | final var sigils = workspace.toSigils( | |
| 103 | KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED ); | |
| 104 | return new YamlSigilOperator( sigils ); | |
| 105 | 77 | } |
| 106 | 78 | } |
| 21 | 21 | */ |
| 22 | 22 | public static TextReplacer getTextReplacer( final int length ) { |
| 23 | // After about 1,500 characters, the StringUtils implementation is slower | |
| 24 | // than the Aho-Corsick algorithm implementation. | |
| 23 | // After about 1,500 characters, the Aho-Corsick algorithm is faster. | |
| 25 | 24 | return length < 1500 ? APACHE : AHO_CORASICK; |
| 26 | 25 | } |
| 27 | 26 | |
| 28 | 27 | /** |
| 29 | 28 | * Convenience method to instantiate a suitable text replacer algorithm and |
| 30 | 29 | * perform a replacement using the given map. At this point, the values should |
| 31 | 30 | * be already dereferenced and ready to be substituted verbatim; any |
| 32 | 31 | * recursively defined values must have been interpolated previously. |
| 33 | 32 | * |
| 34 | * @param text The text containing zero or more variables to replace. | |
| 35 | * @param map The map of variables to their dereferenced values. | |
| 33 | * @param haystack The text containing zero or more variables to replace. | |
| 34 | * @param needles The map of variables to their dereferenced values. | |
| 36 | 35 | * @return The text with all variables replaced. |
| 37 | 36 | */ |
| 38 | 37 | public static String replace( |
| 39 | final String text, final Map<String, String> map ) { | |
| 40 | return getTextReplacer( text.length() ).replace( text, map ); | |
| 38 | final String haystack, final Map<String, String> needles ) { | |
| 39 | return getTextReplacer( haystack.length() ).replace( haystack, needles ); | |
| 41 | 40 | } |
| 42 | 41 | } |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | /** | |
| 5 | * Responsible for defining sigils used within property files. | |
| 6 | */ | |
| 7 | public class PropertyKeyOperator extends SigilKeyOperator { | |
| 8 | public static final String BEGAN = "${"; | |
| 9 | public static final String ENDED = "}"; | |
| 10 | ||
| 11 | /** | |
| 12 | * Constructs a new {@link SigilKeyOperator} subclass with <code>${</code> | |
| 13 | * and <code>}</code> used for the beginning and ending sigils. | |
| 14 | */ | |
| 15 | public PropertyKeyOperator() { | |
| 16 | super( BEGAN, ENDED ); | |
| 17 | } | |
| 18 | } | |
| 1 | 19 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import java.util.function.UnaryOperator; | |
| 5 | ||
| 6 | /** | |
| 7 | * Converts dot-separated variable names into names compatible with R. That is, | |
| 8 | * {@code variable.name.qualified} becomes {@code v$variable$name$qualified}. | |
| 9 | */ | |
| 10 | public final class RKeyOperator implements UnaryOperator<String> { | |
| 11 | private static final char KEY_SEPARATOR_DEF = '.'; | |
| 12 | private static final char KEY_SEPARATOR_R = '$'; | |
| 13 | ||
| 14 | /** | |
| 15 | * Constructs a new instance capable of converting dot-separated variable | |
| 16 | * names into R's dollar-symbol-separated names. | |
| 17 | */ | |
| 18 | public RKeyOperator() {} | |
| 19 | ||
| 20 | /** | |
| 21 | * Transforms a definition key name into the expected format for an R | |
| 22 | * variable key name. | |
| 23 | * <p> | |
| 24 | * This algorithm is faster than {@link String#replace(char, char)}. Faster | |
| 25 | * still would be to cache the values, but that would mean managing the | |
| 26 | * cache when the user changes the beginning and ending of the R delimiters. | |
| 27 | * This code gives about a 2% performance boost when scrolling using | |
| 28 | * cursor keys. After the JIT warms up, this super-minor bottleneck vanishes. | |
| 29 | * | |
| 30 | * @param key The variable name to transform, neither blank nor {@code null}. | |
| 31 | * @return The transformed variable name. | |
| 32 | */ | |
| 33 | @Override | |
| 34 | public String apply( final String key ) { | |
| 35 | assert key != null; | |
| 36 | assert key.length() > 0; | |
| 37 | assert !key.isBlank(); | |
| 38 | ||
| 39 | final var rVarName = new StringBuilder( key.length() + 3 ); | |
| 40 | rVarName.append( "v" ); | |
| 41 | rVarName.append( KEY_SEPARATOR_R ); | |
| 42 | rVarName.append( key ); | |
| 43 | ||
| 44 | // The 3 is for v$ + first char, which cannot be a separator. | |
| 45 | for( int i = rVarName.length() - 1; i >= 3; i-- ) { | |
| 46 | if( rVarName.charAt( i ) == KEY_SEPARATOR_DEF ) { | |
| 47 | rVarName.setCharAt( i, KEY_SEPARATOR_R ); | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | return rVarName.toString(); | |
| 52 | } | |
| 53 | } | |
| 1 | 54 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | /** | |
| 5 | * Brackets variable names between {@link #PREFIX} and {@link #SUFFIX} sigils. | |
| 6 | */ | |
| 7 | public final class RSigilOperator extends SigilOperator { | |
| 8 | public static final String PREFIX = "`r#"; | |
| 9 | public static final char SUFFIX = '`'; | |
| 10 | ||
| 11 | private static final char KEY_SEPARATOR_DEF = '.'; | |
| 12 | private static final char KEY_SEPARATOR_R = '$'; | |
| 13 | ||
| 14 | /** | |
| 15 | * Definition variables are inserted into the document before R variables, | |
| 16 | * so this is required to reformat the definition variable suitable for R. | |
| 17 | */ | |
| 18 | private final SigilOperator mAntecedent; | |
| 19 | ||
| 20 | /** | |
| 21 | * Constructs a new {@link RSigilOperator} capable of wrapping tokens around | |
| 22 | * variable names (keys). | |
| 23 | * | |
| 24 | * @param sigils The starting and ending tokens. | |
| 25 | * @param antecedent The operator to use to undo any previous entokenizing. | |
| 26 | */ | |
| 27 | public RSigilOperator( final Sigils sigils, final SigilOperator antecedent ) { | |
| 28 | super( sigils ); | |
| 29 | ||
| 30 | mAntecedent = antecedent; | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * Returns the given string with backticks prepended and appended. The | |
| 35 | * | |
| 36 | * @param key The string to adorn with R token delimiters. | |
| 37 | * @return PREFIX + delimiterBegan + variableName + delimiterEnded + SUFFIX. | |
| 38 | */ | |
| 39 | @Override | |
| 40 | public String apply( final String key ) { | |
| 41 | assert key != null; | |
| 42 | return PREFIX + getBegan() + key + getEnded() + SUFFIX; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Transforms a definition key (bracketed by token delimiters) into the | |
| 47 | * expected format for an R variable key name. | |
| 48 | * <p> | |
| 49 | * The algorithm to entoken a definition name is faster than | |
| 50 | * {@link String#replace(char, char)}. Faster still would be to cache the | |
| 51 | * values, but that would mean managing the cache when the user changes | |
| 52 | * the beginning and ending of the R delimiters. This code gives about a | |
| 53 | * 2% performance boost when scrolling using cursor keys. After the JIT | |
| 54 | * warms up, this super-minor bottleneck vanishes. | |
| 55 | * </p> | |
| 56 | * | |
| 57 | * @param key The variable name to transform, can be empty but not null. | |
| 58 | * @return The transformed variable name. | |
| 59 | */ | |
| 60 | public String entoken( final String key ) { | |
| 61 | final var detokened = new StringBuilder( key.length() ); | |
| 62 | detokened.append( "v$" ); | |
| 63 | detokened.append( mAntecedent.detoken( key ) ); | |
| 64 | ||
| 65 | // The 3 is for "v$X" where X cannot be a period. | |
| 66 | for( int i = detokened.length() - 1; i >= 3; i-- ) { | |
| 67 | if( detokened.charAt( i ) == KEY_SEPARATOR_DEF ) { | |
| 68 | detokened.setCharAt( i, KEY_SEPARATOR_R ); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | return detokened.toString(); | |
| 73 | } | |
| 74 | } | |
| 75 | 1 |
| 1 | /* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import java.util.function.UnaryOperator; | |
| 5 | import java.util.regex.Matcher; | |
| 6 | import java.util.regex.Pattern; | |
| 7 | ||
| 8 | import static java.lang.String.format; | |
| 9 | import static java.util.regex.Pattern.compile; | |
| 10 | import static java.util.regex.Pattern.quote; | |
| 11 | ||
| 12 | /** | |
| 13 | * Responsible for bracketing definition keys with token delimiters. | |
| 14 | */ | |
| 15 | public class SigilKeyOperator implements UnaryOperator<String> { | |
| 16 | private final String mBegan; | |
| 17 | private final String mEnded; | |
| 18 | private final Pattern mPattern; | |
| 19 | ||
| 20 | public SigilKeyOperator( final String began, final String ended ) { | |
| 21 | assert began != null; | |
| 22 | assert ended != null; | |
| 23 | ||
| 24 | mBegan = began; | |
| 25 | mEnded = ended; | |
| 26 | mPattern = compile( format( "%s(.*?)%s", quote( began ), quote( ended ) ) ); | |
| 27 | } | |
| 28 | ||
| 29 | @Override | |
| 30 | public String apply( final String key ) { | |
| 31 | assert key != null; | |
| 32 | assert !key.startsWith( mBegan ); | |
| 33 | assert !key.endsWith( mEnded ); | |
| 34 | ||
| 35 | return mBegan + key + mEnded; | |
| 36 | } | |
| 37 | ||
| 38 | public Matcher match( final String text ) { | |
| 39 | return mPattern.matcher( text ); | |
| 40 | } | |
| 41 | } | |
| 1 | 42 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import javafx.beans.property.SimpleStringProperty; | |
| 5 | ||
| 6 | import java.util.function.UnaryOperator; | |
| 7 | ||
| 8 | /** | |
| 9 | * Responsible for updating definition keys to use a machine-readable format | |
| 10 | * corresponding to the type of file being edited. This changes a definition | |
| 11 | * key name based on some criteria determined by the factory that creates | |
| 12 | * implementations of this interface. | |
| 13 | */ | |
| 14 | public class SigilOperator implements UnaryOperator<String> { | |
| 15 | private final Sigils mSigils; | |
| 16 | ||
| 17 | /** | |
| 18 | * Defines a new {@link SigilOperator} with the given sigils. | |
| 19 | * | |
| 20 | * @param began The sigil that denotes the start of a variable name. | |
| 21 | * @param ended The sigil that denotes the end of a variable name. | |
| 22 | */ | |
| 23 | public SigilOperator( final String began, final String ended ) { | |
| 24 | this( new Sigils( | |
| 25 | new SimpleStringProperty( began ), | |
| 26 | new SimpleStringProperty( ended ) | |
| 27 | ) ); | |
| 28 | } | |
| 29 | ||
| 30 | SigilOperator( final Sigils sigils ) { | |
| 31 | mSigils = sigils; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Returns the given {@link String} verbatim. Different implementations | |
| 36 | * can override to inject custom behaviours. | |
| 37 | * | |
| 38 | * @param key Returned verbatim. | |
| 39 | */ | |
| 40 | @Override | |
| 41 | public String apply( final String key ) { | |
| 42 | return key; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Wraps the given key in the began and ended tokens. This may perform any | |
| 47 | * preprocessing necessary to ensure the transformation happens. | |
| 48 | * | |
| 49 | * @param key The variable name to transform. | |
| 50 | * @return The given key with before/after sigils to delimit the key name. | |
| 51 | */ | |
| 52 | public String entoken( final String key ) { | |
| 53 | assert key != null; | |
| 54 | return getBegan() + key + getEnded(); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Removes start and stop definition key delimiters from the given key. This | |
| 59 | * method does not check for delimiters, only that there are sufficient | |
| 60 | * characters to remove from either end of the given key. | |
| 61 | * | |
| 62 | * @param key The key adorned with start and stop tokens. | |
| 63 | * @return The given key with the delimiters removed. | |
| 64 | */ | |
| 65 | public String detoken( final String key ) { | |
| 66 | return key; | |
| 67 | } | |
| 68 | ||
| 69 | public Sigils getSigils() { | |
| 70 | return mSigils; | |
| 71 | } | |
| 72 | ||
| 73 | String getBegan() { | |
| 74 | return mSigils.getBegan(); | |
| 75 | } | |
| 76 | ||
| 77 | String getEnded() { | |
| 78 | return mSigils.getEnded(); | |
| 79 | } | |
| 80 | } | |
| 81 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import javafx.beans.property.StringProperty; | |
| 5 | ||
| 6 | import java.util.AbstractMap.SimpleImmutableEntry; | |
| 7 | ||
| 8 | /** | |
| 9 | * Convenience class for pairing a start and an end sigil together. | |
| 10 | */ | |
| 11 | public final class Sigils | |
| 12 | extends SimpleImmutableEntry<StringProperty, StringProperty> { | |
| 13 | ||
| 14 | /** | |
| 15 | * Associates a new key-value pair. | |
| 16 | * | |
| 17 | * @param began The starting sigil. | |
| 18 | * @param ended The ending sigil. | |
| 19 | */ | |
| 20 | public Sigils( final StringProperty began, final StringProperty ended ) { | |
| 21 | super( began, ended ); | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * @return The opening sigil token. | |
| 26 | */ | |
| 27 | public String getBegan() { | |
| 28 | return getKey().get(); | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * @return The closing sigil token, or the empty string if none set. | |
| 33 | */ | |
| 34 | public String getEnded() { | |
| 35 | return getValue().get(); | |
| 36 | } | |
| 37 | } | |
| 38 | 1 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | /** | |
| 5 | * Responsible for bracketing definition keys with token delimiters. | |
| 6 | */ | |
| 7 | public final class YamlSigilOperator extends SigilOperator { | |
| 8 | public YamlSigilOperator( final Sigils sigils ) { | |
| 9 | super( sigils ); | |
| 10 | } | |
| 11 | ||
| 12 | /** | |
| 13 | * Removes start and stop definition key delimiters from the given key. | |
| 14 | * | |
| 15 | * @param key The key that may have start and stop tokens. | |
| 16 | * @return The given key with the delimiters removed. | |
| 17 | */ | |
| 18 | public String detoken( final String key ) { | |
| 19 | final var began = getBegan(); | |
| 20 | final var ended = getEnded(); | |
| 21 | final int bLength = began.length(); | |
| 22 | final int eLength = ended.length(); | |
| 23 | final var bIndex = key.indexOf( began ); | |
| 24 | final var eIndex = key.indexOf( ended, bIndex ); | |
| 25 | final var kLength = key.length(); | |
| 26 | ||
| 27 | return key.substring( | |
| 28 | bIndex == -1 ? 0 : bLength, eIndex == -1 ? kLength : kLength - eLength ); | |
| 29 | } | |
| 30 | } | |
| 31 | 1 |
| 42 | 42 | */ |
| 43 | 43 | public static SpellChecker forLexicon( final String filename ) { |
| 44 | assert filename != null; | |
| 45 | assert !filename.isBlank(); | |
| 46 | ||
| 44 | 47 | try { |
| 45 | 48 | final var lexicon = readLexicon( filename ); |
| ... | ||
| 52 | 55 | |
| 53 | 56 | private static SpellChecker forLexicon( final Map<String, Long> lexicon ) { |
| 54 | assert lexicon != null && !lexicon.isEmpty(); | |
| 57 | assert lexicon != null; | |
| 58 | assert !lexicon.isEmpty(); | |
| 55 | 59 | |
| 56 | 60 | try { |
| ... | ||
| 73 | 77 | */ |
| 74 | 78 | private SymSpellSpeller( final SymSpell symSpell ) { |
| 79 | assert symSpell != null; | |
| 80 | ||
| 75 | 81 | mSymSpell = symSpell; |
| 76 | 82 | } |
| ... | ||
| 86 | 92 | public boolean inLexicon( final String lexeme ) { |
| 87 | 93 | assert lexeme != null; |
| 88 | assert !lexeme.isBlank(); | |
| 94 | assert !lexeme.isEmpty(); | |
| 89 | 95 | |
| 90 | 96 | final var words = lookup( lexeme, CLOSEST ); |
| 91 | 97 | return !words.isEmpty() && lexeme.equals( words.get( 0 ).getSuggestion() ); |
| 92 | 98 | } |
| 93 | 99 | |
| 94 | 100 | @Override |
| 95 | 101 | public List<String> suggestions( final String lexeme, int count ) { |
| 102 | assert lexeme != null; | |
| 103 | assert !lexeme.isEmpty(); | |
| 104 | ||
| 96 | 105 | final List<String> result = new ArrayList<>( count ); |
| 97 | 106 | |
| ... | ||
| 140 | 149 | private static Map<String, Long> readLexicon( final String filename ) |
| 141 | 150 | throws Exception { |
| 151 | assert filename != null; | |
| 152 | assert !filename.isEmpty(); | |
| 153 | ||
| 142 | 154 | final var path = '/' + LEXICONS_DIRECTORY + '/' + filename; |
| 143 | 155 | final var map = new HashMap<String, Long>(); |
| ... | ||
| 171 | 183 | */ |
| 172 | 184 | private boolean isWord( final String word ) { |
| 185 | assert word != null; | |
| 186 | ||
| 173 | 187 | return !word.isBlank() && isLetter( word.charAt( 0 ) ); |
| 174 | 188 | } |
| ... | ||
| 183 | 197 | */ |
| 184 | 198 | private List<SuggestItem> lookup( final String lexeme, final Verbosity v ) { |
| 199 | assert lexeme != null; | |
| 200 | assert v != null; | |
| 201 | ||
| 185 | 202 | return mSymSpell.lookup( lexeme, v ); |
| 186 | 203 | } |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.io.SysFile; |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | import com.keenwrite.util.BoundedCache; | |
| 7 | ||
| 8 | import java.io.*; | |
| 9 | import java.nio.file.NoSuchFileException; | |
| 10 | import java.nio.file.Path; | |
| 11 | import java.util.ArrayList; | |
| 12 | import java.util.List; | |
| 13 | import java.util.Map; | |
| 14 | import java.util.Scanner; | |
| 15 | import java.util.concurrent.Callable; | |
| 16 | import java.util.regex.Pattern; | |
| 17 | ||
| 18 | import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY; | |
| 19 | import static com.keenwrite.events.StatusEvent.clue; | |
| 20 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 21 | import static java.lang.ProcessBuilder.Redirect.DISCARD; | |
| 22 | import static java.lang.String.format; | |
| 23 | import static java.lang.System.currentTimeMillis; | |
| 24 | import static java.lang.System.getProperty; | |
| 25 | import static java.nio.file.Files.*; | |
| 26 | import static java.util.Arrays.asList; | |
| 27 | import static java.util.concurrent.TimeUnit.*; | |
| 28 | import static org.apache.commons.io.FilenameUtils.removeExtension; | |
| 29 | ||
| 30 | /** | |
| 31 | * Responsible for invoking an executable to typeset text. This will | |
| 32 | * construct suitable command-line arguments to invoke the typesetting engine. | |
| 33 | */ | |
| 34 | public class Typesetter { | |
| 35 | private static final SysFile TYPESETTER = new SysFile( "mtxrun" ); | |
| 36 | ||
| 37 | private final Workspace mWorkspace; | |
| 38 | ||
| 39 | /** | |
| 40 | * Creates a new {@link Typesetter} instance capable of configuring the | |
| 41 | * typesetter used to generate a typeset document. | |
| 42 | */ | |
| 43 | public Typesetter( final Workspace workspace ) { | |
| 44 | mWorkspace = workspace; | |
| 45 | } | |
| 46 | ||
| 47 | public static boolean canRun() { | |
| 48 | return TYPESETTER.canRun(); | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * This will typeset the document using a new process. The return value only | |
| 53 | * indicates whether the typesetter exists, not whether the typesetting was | |
| 54 | * successful. | |
| 55 | * | |
| 56 | * @param inputPath The input document to typeset. | |
| 57 | * @param outputPath Path to the finished typeset document. | |
| 58 | * @throws IOException If the process could not be started. | |
| 59 | * @throws InterruptedException If the process was killed. | |
| 60 | * @throws TypesetterNotFoundException When no typesetter is along the PATH. | |
| 61 | */ | |
| 62 | public void typeset( final Path inputPath, final Path outputPath ) | |
| 63 | throws IOException, InterruptedException, TypesetterNotFoundException { | |
| 64 | if( TYPESETTER.canRun() ) { | |
| 65 | clue( "Main.status.typeset.began", outputPath ); | |
| 66 | final var task = new TypesetTask( inputPath, outputPath ); | |
| 67 | final var time = currentTimeMillis(); | |
| 68 | final var success = task.typeset(); | |
| 69 | ||
| 70 | clue( "Main.status.typeset.ended." + (success ? "success" : "failure"), | |
| 71 | outputPath, since( time ) | |
| 72 | ); | |
| 73 | } | |
| 74 | else { | |
| 75 | throw new TypesetterNotFoundException( TYPESETTER.toString() ); | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Calculates the time that has elapsed from the current time to the | |
| 81 | * given moment in time. | |
| 82 | * | |
| 83 | * @param start The starting time, which should be before the current time. | |
| 84 | * @return A human-readable formatted time. | |
| 85 | * @see #asElapsed(long) | |
| 86 | */ | |
| 87 | private static String since( final long start ) { | |
| 88 | return asElapsed( currentTimeMillis() - start ); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Converts an elapsed time to a human-readable format (hours, minutes, | |
| 93 | * seconds, and milliseconds). | |
| 94 | * | |
| 95 | * @param elapsed An elapsed time, in milliseconds. | |
| 96 | * @return Human-readable elapsed time. | |
| 97 | */ | |
| 98 | private static String asElapsed( final long elapsed ) { | |
| 99 | final var hours = MILLISECONDS.toHours( elapsed ); | |
| 100 | final var eHours = elapsed - HOURS.toMillis( hours ); | |
| 101 | final var minutes = MILLISECONDS.toMinutes( eHours ); | |
| 102 | final var eMinutes = eHours - MINUTES.toMillis( minutes ); | |
| 103 | final var seconds = MILLISECONDS.toSeconds( eMinutes ); | |
| 104 | final var eSeconds = eMinutes - SECONDS.toMillis( seconds ); | |
| 105 | final var milliseconds = MILLISECONDS.toMillis( eSeconds ); | |
| 106 | ||
| 107 | return format( "%02d:%02d:%02d.%03d", | |
| 108 | hours, minutes, seconds, milliseconds ); | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Launches a task to typeset a document. | |
| 113 | */ | |
| 114 | private class TypesetTask implements Callable<Boolean> { | |
| 115 | private final List<String> mArgs = new ArrayList<>(); | |
| 116 | private final Path mInput; | |
| 117 | private final Path mOutput; | |
| 118 | ||
| 119 | /** | |
| 120 | * Working directory must be set because ConTeXt cannot write the | |
| 121 | * result to an arbitrary location. | |
| 122 | */ | |
| 123 | private final Path mDirectory; | |
| 124 | ||
| 125 | private TypesetTask( final Path input, final Path output ) { | |
| 126 | assert input != null; | |
| 127 | assert output != null; | |
| 128 | ||
| 129 | final var parentDir = output.getParent(); | |
| 130 | mInput = input; | |
| 131 | mOutput = output; | |
| 132 | mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir; | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Initializes ConTeXt, which means creating the cache directory if it | |
| 137 | * doesn't already exist. The theme entry point must be named 'main.tex'. | |
| 138 | * | |
| 139 | * @return {@code true} if the cache directory exists. | |
| 140 | */ | |
| 141 | private boolean reinitialize() { | |
| 142 | final var filename = mOutput.getFileName(); | |
| 143 | final var themes = getThemesPath(); | |
| 144 | final var theme = getThemesSelection(); | |
| 145 | final var cacheExists = !isEmpty( getCacheDir().toPath() ); | |
| 146 | ||
| 147 | // Ensure invoking multiple times will load the correct arguments. | |
| 148 | mArgs.clear(); | |
| 149 | mArgs.add( TYPESETTER.getName() ); | |
| 150 | ||
| 151 | if( cacheExists ) { | |
| 152 | mArgs.add( "--autogenerate" ); | |
| 153 | mArgs.add( "--script" ); | |
| 154 | mArgs.add( "mtx-context" ); | |
| 155 | mArgs.add( "--batchmode" ); | |
| 156 | mArgs.add( "--nonstopmode" ); | |
| 157 | mArgs.add( "--purgeall" ); | |
| 158 | mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" ); | |
| 159 | mArgs.add( "--environment='main'" ); | |
| 160 | mArgs.add( "--result='" + filename + "'" ); | |
| 161 | mArgs.add( mInput.toString() ); | |
| 162 | ||
| 163 | final var sb = new StringBuilder( 128 ); | |
| 164 | mArgs.forEach( arg -> sb.append( arg ).append( " " ) ); | |
| 165 | clue( sb.toString() ); | |
| 166 | } | |
| 167 | else { | |
| 168 | mArgs.add( "--generate" ); | |
| 169 | } | |
| 170 | ||
| 171 | return cacheExists; | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first | |
| 176 | * try. If the cache directory doesn't exist, attempt to create it, then | |
| 177 | * call ConTeXt to generate the PDF. This is brittle because if the | |
| 178 | * directory is empty, or not populated with cached data, a false positive | |
| 179 | * will be returned, resulting in no PDF being created. | |
| 180 | * | |
| 181 | * @return {@code true} if the document was typeset successfully. | |
| 182 | * @throws IOException If the process could not be started. | |
| 183 | * @throws InterruptedException If the process was killed. | |
| 184 | */ | |
| 185 | private boolean typeset() throws IOException, InterruptedException { | |
| 186 | return reinitialize() ? call() : call() && reinitialize() && call(); | |
| 187 | } | |
| 188 | ||
| 189 | @Override | |
| 190 | public Boolean call() throws IOException, InterruptedException { | |
| 191 | final var stdout = new BoundedCache<String, String>( 150 ); | |
| 192 | final var builder = new ProcessBuilder( mArgs ); | |
| 193 | builder.directory( mDirectory.toFile() ); | |
| 194 | builder.environment().put( "TEXMFCACHE", getCacheDir().toString() ); | |
| 195 | ||
| 196 | // Without redirecting (or draining) stderr, the command may not | |
| 197 | // terminate successfully. | |
| 198 | builder.redirectError( DISCARD ); | |
| 199 | ||
| 200 | final var process = builder.start(); | |
| 201 | final var stream = process.getInputStream(); | |
| 202 | ||
| 203 | // Reading from stdout allows slurping page numbers while generating. | |
| 204 | final var listener = new PaginationListener( stream, stdout ); | |
| 205 | listener.start(); | |
| 206 | ||
| 207 | // Even though the process has completed, there may be incomplete I/O. | |
| 208 | process.waitFor(); | |
| 209 | ||
| 210 | // Allow time for any incomplete I/O to take place. | |
| 211 | process.waitFor( 1, SECONDS ); | |
| 212 | ||
| 213 | final var exit = process.exitValue(); | |
| 214 | process.destroy(); | |
| 215 | ||
| 216 | // If there was an error, the typesetter will leave behind log, pdf, and | |
| 217 | // error files. | |
| 218 | if( exit > 0 ) { | |
| 219 | final var xmlName = mInput.getFileName().toString(); | |
| 220 | final var srcName = mOutput.getFileName().toString(); | |
| 221 | final var logName = newExtension( xmlName, ".log" ); | |
| 222 | final var errName = newExtension( xmlName, "-error.log" ); | |
| 223 | final var pdfName = newExtension( xmlName, ".pdf" ); | |
| 224 | final var tuaName = newExtension( xmlName, ".tua" ); | |
| 225 | final var badName = newExtension( srcName, ".log" ); | |
| 226 | ||
| 227 | log( badName ); | |
| 228 | log( logName ); | |
| 229 | log( errName ); | |
| 230 | log( stdout.keySet().stream().toList() ); | |
| 231 | ||
| 232 | // Users may opt to keep these files around for debugging purposes. | |
| 233 | if( autoclean() ) { | |
| 234 | deleteIfExists( logName ); | |
| 235 | deleteIfExists( errName ); | |
| 236 | deleteIfExists( pdfName ); | |
| 237 | deleteIfExists( badName ); | |
| 238 | deleteIfExists( tuaName ); | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | // Exit value for a successful invocation of the typesetter. This value | |
| 243 | // value is returned when creating the cache on the first run as well as | |
| 244 | // creating PDFs on subsequent runs (after the cache has been created). | |
| 245 | // Users don't care about exit codes, only whether the PDF was generated. | |
| 246 | return exit == 0; | |
| 247 | } | |
| 248 | ||
| 249 | private Path newExtension( final String baseName, final String ext ) { | |
| 250 | return mOutput.resolveSibling( removeExtension( baseName ) + ext ); | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * Fires a status message for each line in the given file. The file format | |
| 255 | * is somewhat machine-readable, but no effort beyond line splitting is | |
| 256 | * made to parse the text. | |
| 257 | * | |
| 258 | * @param path Path to the file containing error messages. | |
| 259 | */ | |
| 260 | private void log( final Path path ) throws IOException { | |
| 261 | if( exists( path ) ) { | |
| 262 | log( readAllLines( path ) ); | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | private void log( final List<String> lines ) { | |
| 267 | final var splits = new ArrayList<String>( lines.size() * 2 ); | |
| 268 | ||
| 269 | for( final var line : lines ) { | |
| 270 | splits.addAll( asList( line.split( "\\\\n" ) ) ); | |
| 271 | } | |
| 272 | ||
| 273 | clue( splits ); | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Returns the location of the cache directory. | |
| 278 | * | |
| 279 | * @return A fully qualified path to the location to store temporary | |
| 280 | * files between typesetting runs. | |
| 281 | */ | |
| 282 | private java.io.File getCacheDir() { | |
| 283 | final var temp = getProperty( "java.io.tmpdir" ); | |
| 284 | final var cache = Path.of( temp, "luatex-cache" ); | |
| 285 | return cache.toFile(); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Answers whether the given directory is empty. The typesetting software | |
| 290 | * creates a non-empty directory by default. The return value from this | |
| 291 | * method is a proxy to answering whether the typesetter has been run for | |
| 292 | * the first time or not. | |
| 293 | * | |
| 294 | * @param path The directory to check for emptiness. | |
| 295 | * @return {@code true} if the directory is empty. | |
| 296 | */ | |
| 297 | private boolean isEmpty( final Path path ) { | |
| 298 | try( final var stream = newDirectoryStream( path ) ) { | |
| 299 | return !stream.iterator().hasNext(); | |
| 300 | } catch( final NoSuchFileException | FileNotFoundException ex ) { | |
| 301 | // A missing directory means it doesn't exist, ergo is empty. | |
| 302 | return true; | |
| 303 | } catch( final IOException ex ) { | |
| 304 | throw new RuntimeException( ex ); | |
| 305 | } | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Responsible for parsing the output from the typesetting engine and | |
| 311 | * updating the status bar to provide assurance that typesetting is | |
| 312 | * executing. | |
| 313 | * | |
| 314 | * <p> | |
| 315 | * Example lines written to standard output: | |
| 316 | * </p> | |
| 317 | * <pre>{@code | |
| 318 | * pages > flushing realpage 15, userpage 15, subpage 15 | |
| 319 | * pages > flushing realpage 16, userpage 16, subpage 16 | |
| 320 | * pages > flushing realpage 1, userpage 1, subpage 1 | |
| 321 | * pages > flushing realpage 2, userpage 2, subpage 2 | |
| 322 | * }</pre> | |
| 323 | * <p> | |
| 324 | * The lines are parsed; the first number is displayed in a status bar | |
| 325 | * message. | |
| 326 | * </p> | |
| 327 | */ | |
| 328 | private static class PaginationListener extends Thread { | |
| 329 | private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" ); | |
| 330 | ||
| 331 | private final InputStream mInputStream; | |
| 332 | ||
| 333 | private final Map<String, String> mCache; | |
| 334 | ||
| 335 | public PaginationListener( | |
| 336 | final InputStream in, final Map<String, String> cache ) { | |
| 337 | mInputStream = in; | |
| 338 | mCache = cache; | |
| 339 | } | |
| 340 | ||
| 341 | @Override | |
| 342 | public void run() { | |
| 343 | try( final var reader = createReader( mInputStream ) ) { | |
| 344 | int pageCount = 1; | |
| 345 | int passCount = 1; | |
| 346 | int pageTotal = 0; | |
| 347 | String line; | |
| 348 | ||
| 349 | while( (line = reader.readLine()) != null ) { | |
| 350 | mCache.put( line, "" ); | |
| 351 | ||
| 352 | if( line.startsWith( "pages" ) ) { | |
| 353 | // The bottleneck will be the typesetting engine writing to stdout, | |
| 354 | // not the parsing of stdout. | |
| 355 | final var scanner = new Scanner( line ).useDelimiter( DIGITS ); | |
| 356 | final var digits = scanner.next(); | |
| 357 | final var page = Integer.parseInt( digits ); | |
| 358 | ||
| 359 | // If the page number is less than the previous page count, it | |
| 360 | // means that the typesetting engine has started another pass. | |
| 361 | if( page < pageCount ) { | |
| 362 | passCount++; | |
| 363 | pageTotal = pageCount; | |
| 364 | } | |
| 365 | ||
| 366 | pageCount = page; | |
| 367 | ||
| 368 | // Inform the user of pages being typeset. | |
| 369 | clue( "Main.status.typeset.page", | |
| 370 | pageCount, pageTotal < 1 ? "?" : pageTotal, passCount | |
| 371 | ); | |
| 372 | } | |
| 373 | } | |
| 374 | } catch( final IOException ex ) { | |
| 375 | clue( ex ); | |
| 376 | throw new RuntimeException( ex ); | |
| 377 | } | |
| 378 | } | |
| 379 | ||
| 380 | private BufferedReader createReader( final InputStream inputStream ) { | |
| 381 | return new BufferedReader( new InputStreamReader( inputStream ) ); | |
| 382 | } | |
| 383 | } | |
| 384 | ||
| 385 | private File getThemesPath() { | |
| 386 | return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 387 | } | |
| 388 | ||
| 389 | private String getThemesSelection() { | |
| 390 | return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 391 | } | |
| 392 | ||
| 393 | /** | |
| 394 | * Answers whether logs and other files should be deleted upon error. The | |
| 395 | * log files are useful for debugging. | |
| 396 | * | |
| 397 | * @return {@code true} to delete generated files. | |
| 398 | */ | |
| 399 | public boolean autoclean() { | |
| 400 | return mWorkspace.toBoolean( KEY_TYPESET_CONTEXT_CLEAN ); | |
| 5 | import com.keenwrite.collections.BoundedCache; | |
| 6 | import com.keenwrite.util.GenericBuilder; | |
| 7 | ||
| 8 | import java.io.*; | |
| 9 | import java.nio.file.NoSuchFileException; | |
| 10 | import java.nio.file.Path; | |
| 11 | import java.util.ArrayList; | |
| 12 | import java.util.List; | |
| 13 | import java.util.Map; | |
| 14 | import java.util.Scanner; | |
| 15 | import java.util.concurrent.Callable; | |
| 16 | import java.util.regex.Pattern; | |
| 17 | ||
| 18 | import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY; | |
| 19 | import static com.keenwrite.events.StatusEvent.clue; | |
| 20 | import static java.lang.ProcessBuilder.Redirect.DISCARD; | |
| 21 | import static java.lang.String.format; | |
| 22 | import static java.lang.System.currentTimeMillis; | |
| 23 | import static java.lang.System.getProperty; | |
| 24 | import static java.nio.file.Files.*; | |
| 25 | import static java.util.Arrays.asList; | |
| 26 | import static java.util.concurrent.TimeUnit.*; | |
| 27 | import static org.apache.commons.io.FilenameUtils.removeExtension; | |
| 28 | ||
| 29 | /** | |
| 30 | * Responsible for invoking an executable to typeset text. This will | |
| 31 | * construct suitable command-line arguments to invoke the typesetting engine. | |
| 32 | */ | |
| 33 | public class Typesetter { | |
| 34 | private static final SysFile TYPESETTER = new SysFile( "mtxrun" ); | |
| 35 | ||
| 36 | private final Mutator mMutator; | |
| 37 | ||
| 38 | public static GenericBuilder<Mutator, Typesetter> builder() { | |
| 39 | return GenericBuilder.of( Mutator::new, Typesetter::new ); | |
| 40 | } | |
| 41 | ||
| 42 | public static final class Mutator { | |
| 43 | private Path mInputPath; | |
| 44 | private Path mOutputPath; | |
| 45 | private Path mThemePath; | |
| 46 | private String mThemeName; | |
| 47 | private boolean mAutoclean; | |
| 48 | ||
| 49 | /** | |
| 50 | * @param inputPath The input document to typeset. | |
| 51 | */ | |
| 52 | public void setInputPath( final Path inputPath ) { | |
| 53 | mInputPath = inputPath; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * @param outputPath Path to the finished typeset document to create. | |
| 58 | */ | |
| 59 | public void setOutputPath( final Path outputPath ) { | |
| 60 | mOutputPath = outputPath; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * @param themePath Fully qualified path to the theme directory. | |
| 65 | */ | |
| 66 | public void setThemePath( final Path themePath ) { | |
| 67 | mThemePath = themePath; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * @param themePath Fully qualified path to the theme directory. | |
| 72 | */ | |
| 73 | public void setThemePath( final File themePath ) { | |
| 74 | setThemePath( themePath.toPath() ); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * @param themeName Name of theme to apply when generating the PDF file. | |
| 79 | */ | |
| 80 | public void setThemeName( final String themeName ) { | |
| 81 | mThemeName = themeName; | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * @param autoclean {@code true} to remove all temporary files after | |
| 86 | * typesetter produces a PDF file. | |
| 87 | */ | |
| 88 | public void setAutoclean( final boolean autoclean ) { | |
| 89 | mAutoclean = autoclean; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | public static boolean canRun() { | |
| 94 | return TYPESETTER.canRun(); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Calculates the time that has elapsed from the current time to the | |
| 99 | * given moment in time. | |
| 100 | * | |
| 101 | * @param start The starting time, which should be before the current time. | |
| 102 | * @return A human-readable formatted time. | |
| 103 | * @see #asElapsed(long) | |
| 104 | */ | |
| 105 | private static String since( final long start ) { | |
| 106 | return asElapsed( currentTimeMillis() - start ); | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Converts an elapsed time to a human-readable format (hours, minutes, | |
| 111 | * seconds, and milliseconds). | |
| 112 | * | |
| 113 | * @param elapsed An elapsed time, in milliseconds. | |
| 114 | * @return Human-readable elapsed time. | |
| 115 | */ | |
| 116 | private static String asElapsed( final long elapsed ) { | |
| 117 | final var hours = MILLISECONDS.toHours( elapsed ); | |
| 118 | final var eHours = elapsed - HOURS.toMillis( hours ); | |
| 119 | final var minutes = MILLISECONDS.toMinutes( eHours ); | |
| 120 | final var eMinutes = eHours - MINUTES.toMillis( minutes ); | |
| 121 | final var seconds = MILLISECONDS.toSeconds( eMinutes ); | |
| 122 | final var eSeconds = eMinutes - SECONDS.toMillis( seconds ); | |
| 123 | final var milliseconds = MILLISECONDS.toMillis( eSeconds ); | |
| 124 | ||
| 125 | return format( "%02d:%02d:%02d.%03d", | |
| 126 | hours, minutes, seconds, milliseconds ); | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Launches a task to typeset a document. | |
| 131 | */ | |
| 132 | private class TypesetTask implements Callable<Boolean> { | |
| 133 | private final List<String> mArgs = new ArrayList<>(); | |
| 134 | ||
| 135 | /** | |
| 136 | * Working directory must be set because ConTeXt cannot write the | |
| 137 | * result to an arbitrary location. | |
| 138 | */ | |
| 139 | private final Path mDirectory; | |
| 140 | ||
| 141 | private TypesetTask() { | |
| 142 | final var parentDir = getOutputPath().getParent(); | |
| 143 | mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir; | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Initializes ConTeXt, which means creating the cache directory if it | |
| 148 | * doesn't already exist. The theme entry point must be named 'main.tex'. | |
| 149 | * | |
| 150 | * @return {@code true} if the cache directory exists. | |
| 151 | */ | |
| 152 | private boolean reinitialize() { | |
| 153 | final var filename = getOutputPath().getFileName(); | |
| 154 | final var themes = getThemePath(); | |
| 155 | final var theme = getThemeName(); | |
| 156 | final var cacheExists = !isEmpty( getCacheDir().toPath() ); | |
| 157 | ||
| 158 | // Ensure invoking multiple times will load the correct arguments. | |
| 159 | mArgs.clear(); | |
| 160 | mArgs.add( TYPESETTER.getName() ); | |
| 161 | ||
| 162 | if( cacheExists ) { | |
| 163 | mArgs.add( "--autogenerate" ); | |
| 164 | mArgs.add( "--script" ); | |
| 165 | mArgs.add( "mtx-context" ); | |
| 166 | mArgs.add( "--batchmode" ); | |
| 167 | mArgs.add( "--nonstopmode" ); | |
| 168 | mArgs.add( "--purgeall" ); | |
| 169 | mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" ); | |
| 170 | mArgs.add( "--environment='main'" ); | |
| 171 | mArgs.add( "--result='" + filename + "'" ); | |
| 172 | mArgs.add( getInputPath().toString() ); | |
| 173 | ||
| 174 | final var sb = new StringBuilder( 128 ); | |
| 175 | mArgs.forEach( arg -> sb.append( arg ).append( " " ) ); | |
| 176 | clue( sb.toString() ); | |
| 177 | } | |
| 178 | else { | |
| 179 | mArgs.add( "--generate" ); | |
| 180 | } | |
| 181 | ||
| 182 | return cacheExists; | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first | |
| 187 | * try. If the cache directory doesn't exist, attempt to create it, then | |
| 188 | * call ConTeXt to generate the PDF. This is brittle because if the | |
| 189 | * directory is empty, or not populated with cached data, a false positive | |
| 190 | * will be returned, resulting in no PDF being created. | |
| 191 | * | |
| 192 | * @return {@code true} if the document was typeset successfully. | |
| 193 | * @throws IOException If the process could not be started. | |
| 194 | * @throws InterruptedException If the process was killed. | |
| 195 | */ | |
| 196 | private boolean typeset() throws IOException, InterruptedException { | |
| 197 | return reinitialize() ? call() : call() && reinitialize() && call(); | |
| 198 | } | |
| 199 | ||
| 200 | @Override | |
| 201 | public Boolean call() throws IOException, InterruptedException { | |
| 202 | final var stdout = new BoundedCache<String, String>( 150 ); | |
| 203 | final var builder = new ProcessBuilder( mArgs ); | |
| 204 | builder.directory( mDirectory.toFile() ); | |
| 205 | builder.environment().put( "TEXMFCACHE", getCacheDir().toString() ); | |
| 206 | ||
| 207 | // Without redirecting (or draining) stderr, the command may not | |
| 208 | // terminate successfully. | |
| 209 | builder.redirectError( DISCARD ); | |
| 210 | ||
| 211 | final var process = builder.start(); | |
| 212 | final var stream = process.getInputStream(); | |
| 213 | ||
| 214 | // Reading from stdout allows slurping page numbers while generating. | |
| 215 | final var listener = new PaginationListener( stream, stdout ); | |
| 216 | listener.start(); | |
| 217 | ||
| 218 | // Even though the process has completed, there may be incomplete I/O. | |
| 219 | process.waitFor(); | |
| 220 | ||
| 221 | // Allow time for any incomplete I/O to take place. | |
| 222 | process.waitFor( 1, SECONDS ); | |
| 223 | ||
| 224 | final var exit = process.exitValue(); | |
| 225 | process.destroy(); | |
| 226 | ||
| 227 | // If there was an error, the typesetter will leave behind log, pdf, and | |
| 228 | // error files. | |
| 229 | if( exit > 0 ) { | |
| 230 | final var xmlName = getInputPath().getFileName().toString(); | |
| 231 | final var srcName = getOutputPath().getFileName().toString(); | |
| 232 | final var logName = newExtension( xmlName, ".log" ); | |
| 233 | final var errName = newExtension( xmlName, "-error.log" ); | |
| 234 | final var pdfName = newExtension( xmlName, ".pdf" ); | |
| 235 | final var tuaName = newExtension( xmlName, ".tua" ); | |
| 236 | final var badName = newExtension( srcName, ".log" ); | |
| 237 | ||
| 238 | log( badName ); | |
| 239 | log( logName ); | |
| 240 | log( errName ); | |
| 241 | log( stdout.keySet().stream().toList() ); | |
| 242 | ||
| 243 | // Users may opt to keep these files around for debugging purposes. | |
| 244 | if( autoclean() ) { | |
| 245 | deleteIfExists( logName ); | |
| 246 | deleteIfExists( errName ); | |
| 247 | deleteIfExists( pdfName ); | |
| 248 | deleteIfExists( badName ); | |
| 249 | deleteIfExists( tuaName ); | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | // Exit value for a successful invocation of the typesetter. This value | |
| 254 | // value is returned when creating the cache on the first run as well as | |
| 255 | // creating PDFs on subsequent runs (after the cache has been created). | |
| 256 | // Users don't care about exit codes, only whether the PDF was generated. | |
| 257 | return exit == 0; | |
| 258 | } | |
| 259 | ||
| 260 | private Path newExtension( final String baseName, final String ext ) { | |
| 261 | return getOutputPath().resolveSibling( removeExtension( baseName ) + ext ); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Fires a status message for each line in the given file. The file format | |
| 266 | * is somewhat machine-readable, but no effort beyond line splitting is | |
| 267 | * made to parse the text. | |
| 268 | * | |
| 269 | * @param path Path to the file containing error messages. | |
| 270 | */ | |
| 271 | private void log( final Path path ) throws IOException { | |
| 272 | if( exists( path ) ) { | |
| 273 | log( readAllLines( path ) ); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | private void log( final List<String> lines ) { | |
| 278 | final var splits = new ArrayList<String>( lines.size() * 2 ); | |
| 279 | ||
| 280 | for( final var line : lines ) { | |
| 281 | splits.addAll( asList( line.split( "\\\\n" ) ) ); | |
| 282 | } | |
| 283 | ||
| 284 | clue( splits ); | |
| 285 | } | |
| 286 | ||
| 287 | /** | |
| 288 | * Returns the location of the cache directory. | |
| 289 | * | |
| 290 | * @return A fully qualified path to the location to store temporary | |
| 291 | * files between typesetting runs. | |
| 292 | */ | |
| 293 | private java.io.File getCacheDir() { | |
| 294 | final var temp = getProperty( "java.io.tmpdir" ); | |
| 295 | final var cache = Path.of( temp, "luatex-cache" ); | |
| 296 | return cache.toFile(); | |
| 297 | } | |
| 298 | ||
| 299 | /** | |
| 300 | * Answers whether the given directory is empty. The typesetting software | |
| 301 | * creates a non-empty directory by default. The return value from this | |
| 302 | * method is a proxy to answering whether the typesetter has been run for | |
| 303 | * the first time or not. | |
| 304 | * | |
| 305 | * @param path The directory to check for emptiness. | |
| 306 | * @return {@code true} if the directory is empty. | |
| 307 | */ | |
| 308 | private boolean isEmpty( final Path path ) { | |
| 309 | try( final var stream = newDirectoryStream( path ) ) { | |
| 310 | return !stream.iterator().hasNext(); | |
| 311 | } catch( final NoSuchFileException | FileNotFoundException ex ) { | |
| 312 | // A missing directory means it doesn't exist, ergo is empty. | |
| 313 | return true; | |
| 314 | } catch( final IOException ex ) { | |
| 315 | throw new RuntimeException( ex ); | |
| 316 | } | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | /** | |
| 321 | * Responsible for parsing the output from the typesetting engine and | |
| 322 | * updating the status bar to provide assurance that typesetting is | |
| 323 | * executing. | |
| 324 | * | |
| 325 | * <p> | |
| 326 | * Example lines written to standard output: | |
| 327 | * </p> | |
| 328 | * <pre>{@code | |
| 329 | * pages > flushing realpage 15, userpage 15, subpage 15 | |
| 330 | * pages > flushing realpage 16, userpage 16, subpage 16 | |
| 331 | * pages > flushing realpage 1, userpage 1, subpage 1 | |
| 332 | * pages > flushing realpage 2, userpage 2, subpage 2 | |
| 333 | * }</pre> | |
| 334 | * <p> | |
| 335 | * The lines are parsed; the first number is displayed in a status bar | |
| 336 | * message. | |
| 337 | * </p> | |
| 338 | */ | |
| 339 | private static class PaginationListener extends Thread { | |
| 340 | private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" ); | |
| 341 | ||
| 342 | private final InputStream mInputStream; | |
| 343 | ||
| 344 | private final Map<String, String> mCache; | |
| 345 | ||
| 346 | public PaginationListener( | |
| 347 | final InputStream in, final Map<String, String> cache ) { | |
| 348 | mInputStream = in; | |
| 349 | mCache = cache; | |
| 350 | } | |
| 351 | ||
| 352 | @Override | |
| 353 | public void run() { | |
| 354 | try( final var reader = createReader( mInputStream ) ) { | |
| 355 | int pageCount = 1; | |
| 356 | int passCount = 1; | |
| 357 | int pageTotal = 0; | |
| 358 | String line; | |
| 359 | ||
| 360 | while( (line = reader.readLine()) != null ) { | |
| 361 | mCache.put( line, "" ); | |
| 362 | ||
| 363 | if( line.startsWith( "pages" ) ) { | |
| 364 | // The bottleneck will be the typesetting engine writing to stdout, | |
| 365 | // not the parsing of stdout. | |
| 366 | final var scanner = new Scanner( line ).useDelimiter( DIGITS ); | |
| 367 | final var digits = scanner.next(); | |
| 368 | final var page = Integer.parseInt( digits ); | |
| 369 | ||
| 370 | // If the page number is less than the previous page count, it | |
| 371 | // means that the typesetting engine has started another pass. | |
| 372 | if( page < pageCount ) { | |
| 373 | passCount++; | |
| 374 | pageTotal = pageCount; | |
| 375 | } | |
| 376 | ||
| 377 | pageCount = page; | |
| 378 | ||
| 379 | // Inform the user of pages being typeset. | |
| 380 | clue( "Main.status.typeset.page", | |
| 381 | pageCount, pageTotal < 1 ? "?" : pageTotal, passCount | |
| 382 | ); | |
| 383 | } | |
| 384 | } | |
| 385 | } catch( final IOException ex ) { | |
| 386 | clue( ex ); | |
| 387 | throw new RuntimeException( ex ); | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | private BufferedReader createReader( final InputStream inputStream ) { | |
| 392 | return new BufferedReader( new InputStreamReader( inputStream ) ); | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Creates a new {@link Typesetter} instance capable of configuring the | |
| 398 | * typesetter used to generate a typeset document. | |
| 399 | */ | |
| 400 | private Typesetter( final Mutator mutator ) { | |
| 401 | assert mutator != null; | |
| 402 | ||
| 403 | mMutator = mutator; | |
| 404 | } | |
| 405 | ||
| 406 | /** | |
| 407 | * This will typeset the document using a new process. The return value only | |
| 408 | * indicates whether the typesetter exists, not whether the typesetting was | |
| 409 | * successful. | |
| 410 | * | |
| 411 | * @throws IOException If the process could not be started. | |
| 412 | * @throws InterruptedException If the process was killed. | |
| 413 | * @throws TypesetterNotFoundException When no typesetter is along the PATH. | |
| 414 | */ | |
| 415 | public void typeset() | |
| 416 | throws IOException, InterruptedException, TypesetterNotFoundException { | |
| 417 | if( TYPESETTER.canRun() ) { | |
| 418 | final var outputPath = getOutputPath(); | |
| 419 | ||
| 420 | clue( "Main.status.typeset.began", outputPath ); | |
| 421 | final var task = new TypesetTask(); | |
| 422 | final var time = currentTimeMillis(); | |
| 423 | final var success = task.typeset(); | |
| 424 | ||
| 425 | clue( "Main.status.typeset.ended." + (success ? "success" : "failure"), | |
| 426 | outputPath, since( time ) | |
| 427 | ); | |
| 428 | } | |
| 429 | else { | |
| 430 | throw new TypesetterNotFoundException( TYPESETTER.toString() ); | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | private Path getInputPath() { | |
| 435 | return mMutator.mInputPath; | |
| 436 | } | |
| 437 | ||
| 438 | private Path getOutputPath() { | |
| 439 | return mMutator.mOutputPath; | |
| 440 | } | |
| 441 | ||
| 442 | private Path getThemePath() { | |
| 443 | return mMutator.mThemePath; | |
| 444 | } | |
| 445 | ||
| 446 | private String getThemeName() { | |
| 447 | return mMutator.mThemeName; | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * Answers whether logs and other files should be deleted upon error. The | |
| 452 | * log files are useful for debugging. | |
| 453 | * | |
| 454 | * @return {@code true} to delete generated files. | |
| 455 | */ | |
| 456 | public boolean autoclean() { | |
| 457 | return mMutator.mAutoclean; | |
| 401 | 458 | } |
| 402 | 459 | } |
| 29 | 29 | private final List<MenuAction> mSubActions = new ArrayList<>(); |
| 30 | 30 | |
| 31 | /** | |
| 32 | * Provides a fluent interface around constructing actions so that duplication | |
| 33 | * can be avoided. | |
| 34 | */ | |
| 35 | public static class Builder { | |
| 36 | private String mText; | |
| 37 | private String mAccelerator; | |
| 38 | private String mIcon; | |
| 39 | private EventHandler<ActionEvent> mHandler; | |
| 40 | ||
| 41 | /** | |
| 42 | * Sets the text, icon, and accelerator for a given action identifier. | |
| 43 | * See the messages properties file for details. | |
| 44 | * | |
| 45 | * @param id The identifier to look up in the properties file. | |
| 46 | * @return An instance of {@link Builder} that can be built into an | |
| 47 | * instance of {@link Action}. | |
| 48 | */ | |
| 49 | public Builder setId( final String id ) { | |
| 50 | final var prefix = ACTION_PREFIX + id + "."; | |
| 51 | final var text = prefix + "text"; | |
| 52 | final var icon = prefix + "icon"; | |
| 53 | final var accelerator = prefix + "accelerator"; | |
| 54 | final var builder = setText( text ).setIcon( icon ); | |
| 55 | ||
| 56 | return Messages.containsKey( accelerator ) | |
| 57 | ? builder.setAccelerator( Messages.get( accelerator ) ) | |
| 58 | : builder; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Sets the action text based on a resource bundle key. | |
| 63 | * | |
| 64 | * @param key The key to look up in the {@link Messages}. | |
| 65 | * @return The corresponding value, or the key name if none found. | |
| 66 | */ | |
| 67 | private Builder setText( final String key ) { | |
| 68 | mText = Messages.get( key, key ); | |
| 69 | return this; | |
| 70 | } | |
| 71 | ||
| 72 | private Builder setAccelerator( final String accelerator ) { | |
| 73 | mAccelerator = accelerator; | |
| 74 | return this; | |
| 75 | } | |
| 76 | ||
| 77 | private Builder setIcon( final String iconKey ) { | |
| 78 | assert iconKey != null; | |
| 79 | ||
| 80 | // If there's no icon associated with the icon key name, don't attempt | |
| 81 | // to create a graphic for the icon, because it won't exist. | |
| 82 | final var iconName = Messages.get( iconKey ); | |
| 83 | mIcon = iconKey.equals( iconName ) ? "" : iconName; | |
| 84 | ||
| 85 | return this; | |
| 86 | } | |
| 87 | ||
| 88 | public Builder setHandler( final EventHandler<ActionEvent> handler ) { | |
| 89 | mHandler = handler; | |
| 90 | return this; | |
| 91 | } | |
| 92 | ||
| 93 | public Action build() { | |
| 94 | return new Action( mText, mAccelerator, mIcon, mHandler ); | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * TODO: Reuse the {@link GenericBuilder}. | |
| 100 | * | |
| 101 | * @return The {@link Builder} for an instance of {@link Action}. | |
| 102 | */ | |
| 103 | public static Builder builder() { | |
| 104 | return new Builder(); | |
| 105 | } | |
| 106 | ||
| 107 | private static Button createIconButton( final String icon ) { | |
| 108 | return new Button( null, createGraphic( icon ) ); | |
| 109 | } | |
| 110 | ||
| 31 | 111 | public Action( |
| 32 | 112 | final String text, |
| ... | ||
| 80 | 160 | @Override |
| 81 | 161 | public Button createToolBarNode() { |
| 82 | final var button = createIconButton(); | |
| 162 | final var button = createIconButton( mIcon ); | |
| 83 | 163 | var tooltip = mText; |
| 84 | 164 | |
| ... | ||
| 100 | 180 | |
| 101 | 181 | return button; |
| 102 | } | |
| 103 | ||
| 104 | private Button createIconButton() { | |
| 105 | return new Button( null, createGraphic( mIcon ) ); | |
| 106 | 182 | } |
| 107 | 183 | |
| ... | ||
| 116 | 192 | mSubActions.addAll( List.of( action ) ); |
| 117 | 193 | return this; |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * TODO: Reuse the {@link GenericBuilder}. | |
| 122 | * | |
| 123 | * @return The {@link Builder} for an instance of {@link Action}. | |
| 124 | */ | |
| 125 | public static Builder builder() { | |
| 126 | return new Builder(); | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * Provides a fluent interface around constructing actions so that duplication | |
| 131 | * can be avoided. | |
| 132 | */ | |
| 133 | public static class Builder { | |
| 134 | private String mText; | |
| 135 | private String mAccelerator; | |
| 136 | private String mIcon; | |
| 137 | private EventHandler<ActionEvent> mHandler; | |
| 138 | ||
| 139 | /** | |
| 140 | * Sets the text, icon, and accelerator for a given action identifier. | |
| 141 | * See the messages properties file for details. | |
| 142 | * | |
| 143 | * @param id The identifier to look up in the properties file. | |
| 144 | * @return An instance of {@link Builder} that can be built into an | |
| 145 | * instance of {@link Action}. | |
| 146 | */ | |
| 147 | public Builder setId( final String id ) { | |
| 148 | final var prefix = ACTION_PREFIX + id + "."; | |
| 149 | final var text = prefix + "text"; | |
| 150 | final var icon = prefix + "icon"; | |
| 151 | final var accelerator = prefix + "accelerator"; | |
| 152 | final var builder = setText( text ).setIcon( icon ); | |
| 153 | ||
| 154 | return Messages.containsKey( accelerator ) | |
| 155 | ? builder.setAccelerator( Messages.get( accelerator ) ) | |
| 156 | : builder; | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Sets the action text based on a resource bundle key. | |
| 161 | * | |
| 162 | * @param key The key to look up in the {@link Messages}. | |
| 163 | * @return The corresponding value, or the key name if none found. | |
| 164 | */ | |
| 165 | private Builder setText( final String key ) { | |
| 166 | mText = Messages.get( key, key ); | |
| 167 | return this; | |
| 168 | } | |
| 169 | ||
| 170 | private Builder setAccelerator( final String accelerator ) { | |
| 171 | mAccelerator = accelerator; | |
| 172 | return this; | |
| 173 | } | |
| 174 | ||
| 175 | private Builder setIcon( final String iconKey ) { | |
| 176 | assert iconKey != null; | |
| 177 | ||
| 178 | // If there's no icon associated with the icon key name, don't attempt | |
| 179 | // to create a graphic for the icon, because it won't exist. | |
| 180 | final var iconName = Messages.get( iconKey ); | |
| 181 | mIcon = iconKey.equals( iconName ) ? "" : iconName; | |
| 182 | ||
| 183 | return this; | |
| 184 | } | |
| 185 | ||
| 186 | public Builder setHandler( final EventHandler<ActionEvent> handler ) { | |
| 187 | mHandler = handler; | |
| 188 | return this; | |
| 189 | } | |
| 190 | ||
| 191 | public Action build() { | |
| 192 | return new Action( mText, mAccelerator, mIcon, mHandler ); | |
| 193 | } | |
| 194 | 194 | } |
| 195 | 195 | } |
| 44 | 44 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; |
| 45 | 45 | import static com.keenwrite.events.StatusEvent.clue; |
| 46 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 47 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 48 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options; | |
| 49 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*; | |
| 50 | import static com.keenwrite.util.FileWalker.walk; | |
| 51 | import static java.nio.file.Files.readString; | |
| 52 | import static java.nio.file.Files.writeString; | |
| 53 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 54 | import static javafx.application.Platform.runLater; | |
| 55 | import static javafx.event.Event.fireEvent; | |
| 56 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 57 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 58 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 59 | ||
| 60 | /** | |
| 61 | * Responsible for abstracting how functionality is mapped to the application. | |
| 62 | * This allows users to customize accelerator keys and will provide pluggable | |
| 63 | * functionality so that different text markup languages can change documents | |
| 64 | * using their respective syntax. | |
| 65 | */ | |
| 66 | public final class GuiCommands { | |
| 67 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 68 | ||
| 69 | private static final String STYLE_SEARCH = "search"; | |
| 70 | ||
| 71 | /** | |
| 72 | * Sci-fi genres, which are can be longer than other genres, typically fall | |
| 73 | * below 150,000 words at 6 chars per word. This reduces re-allocations of | |
| 74 | * memory when concatenating files together when exporting novels. | |
| 75 | */ | |
| 76 | private static final int DOCUMENT_LENGTH = 150_000 * 6; | |
| 77 | ||
| 78 | /** | |
| 79 | * When an action is executed, this is one of the recipients. | |
| 80 | */ | |
| 81 | private final MainPane mMainPane; | |
| 82 | ||
| 83 | private final MainScene mMainScene; | |
| 84 | ||
| 85 | private final LogView mLogView; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks finding text in the active document. | |
| 89 | */ | |
| 90 | private final SearchModel mSearchModel; | |
| 91 | ||
| 92 | public GuiCommands( final MainScene scene, final MainPane pane ) { | |
| 93 | mMainScene = scene; | |
| 94 | mMainPane = pane; | |
| 95 | mLogView = new LogView(); | |
| 96 | mSearchModel = new SearchModel(); | |
| 97 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 98 | final var editor = getActiveTextEditor(); | |
| 99 | ||
| 100 | // Clear highlighted areas before highlighting a new region. | |
| 101 | if( o != null ) { | |
| 102 | editor.unstylize( STYLE_SEARCH ); | |
| 103 | } | |
| 104 | ||
| 105 | if( n != null ) { | |
| 106 | editor.moveTo( n.getStart() ); | |
| 107 | editor.stylize( n, STYLE_SEARCH ); | |
| 108 | } | |
| 109 | } ); | |
| 110 | ||
| 111 | // When the active text editor changes, update the haystack. | |
| 112 | mMainPane.activeTextEditorProperty().addListener( | |
| 113 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 114 | ); | |
| 115 | } | |
| 116 | ||
| 117 | public void file_new() { | |
| 118 | getMainPane().newTextEditor(); | |
| 119 | } | |
| 120 | ||
| 121 | public void file_open() { | |
| 122 | pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | |
| 123 | } | |
| 124 | ||
| 125 | public void file_close() { | |
| 126 | getMainPane().close(); | |
| 127 | } | |
| 128 | ||
| 129 | public void file_close_all() { | |
| 130 | getMainPane().closeAll(); | |
| 131 | } | |
| 132 | ||
| 133 | public void file_save() { | |
| 134 | getMainPane().save(); | |
| 135 | } | |
| 136 | ||
| 137 | public void file_save_as() { | |
| 138 | pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | |
| 139 | } | |
| 140 | ||
| 141 | public void file_save_all() { | |
| 142 | getMainPane().saveAll(); | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Converts the actively edited file in the given file format. | |
| 147 | * | |
| 148 | * @param format The destination file format. | |
| 149 | */ | |
| 150 | private void file_export( final ExportFormat format ) { | |
| 151 | file_export( format, false ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Converts one or more files into the given file format. If {@code dir} | |
| 156 | * is set to true, this will first append all files in the same directory | |
| 157 | * as the actively edited file. | |
| 158 | * | |
| 159 | * @param format The destination file format. | |
| 160 | * @param dir Export all files in the actively edited file's directory. | |
| 161 | */ | |
| 162 | private void file_export( final ExportFormat format, final boolean dir ) { | |
| 163 | final var main = getMainPane(); | |
| 164 | final var editor = main.getActiveTextEditor(); | |
| 165 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 166 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 167 | final var selection = pickFiles( | |
| 168 | Constants.PDF_DEFAULT.getName().equals( exported.get().getName() ) | |
| 169 | ? filename | |
| 170 | : exported.get(), FILE_EXPORT | |
| 171 | ); | |
| 172 | ||
| 173 | selection.ifPresent( ( files ) -> { | |
| 174 | editor.save(); | |
| 175 | ||
| 176 | final var file = files.get( 0 ); | |
| 177 | final var path = file.toPath(); | |
| 178 | final var document = dir ? append( editor ) : editor.getText(); | |
| 179 | final var context = main.createProcessorContext( path, format ); | |
| 180 | ||
| 181 | final var task = new Task<Path>() { | |
| 182 | @Override | |
| 183 | protected Path call() throws Exception { | |
| 184 | final var chain = createProcessors( context ); | |
| 185 | final var export = chain.apply( document ); | |
| 186 | ||
| 187 | // Processors can export binary files. In such cases, processors | |
| 188 | // return null to prevent further processing. | |
| 189 | return export == null ? null : writeString( path, export ); | |
| 190 | } | |
| 191 | }; | |
| 192 | ||
| 193 | task.setOnSucceeded( | |
| 194 | e -> { | |
| 195 | // Remember the exported file name for next time. | |
| 196 | exported.setValue( file ); | |
| 197 | ||
| 198 | final var result = task.getValue(); | |
| 199 | ||
| 200 | // Binary formats must notify users of success independently. | |
| 201 | if( result != null ) { | |
| 202 | clue( "Main.status.export.success", result ); | |
| 203 | } | |
| 204 | } | |
| 205 | ); | |
| 206 | ||
| 207 | task.setOnFailed( e -> { | |
| 208 | final var ex = task.getException(); | |
| 209 | clue( ex ); | |
| 210 | ||
| 211 | if( ex instanceof TypeNotPresentException ) { | |
| 212 | fireExportFailedEvent(); | |
| 213 | } | |
| 214 | } ); | |
| 215 | ||
| 216 | sExecutor.execute( task ); | |
| 217 | } ); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * @param dir {@code true} means to export all files in the active file | |
| 222 | * editor's directory; {@code false} means to export only the | |
| 223 | * actively edited file. | |
| 224 | */ | |
| 225 | private void file_export_pdf( final boolean dir ) { | |
| 226 | final var workspace = getWorkspace(); | |
| 227 | final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 228 | final var theme = workspace.stringProperty( | |
| 229 | KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 230 | ||
| 231 | if( Typesetter.canRun() ) { | |
| 232 | // If the typesetter is installed, allow the user to select a theme. If | |
| 233 | // the themes aren't installed, a status message will appear. | |
| 234 | if( ThemePicker.choose( themes, theme ) ) { | |
| 235 | file_export( APPLICATION_PDF, dir ); | |
| 236 | } | |
| 237 | } | |
| 238 | else { | |
| 239 | fireExportFailedEvent(); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | public void file_export_pdf() { | |
| 244 | file_export_pdf( false ); | |
| 245 | } | |
| 246 | ||
| 247 | public void file_export_pdf_dir() { | |
| 248 | file_export_pdf( true ); | |
| 249 | } | |
| 250 | ||
| 251 | public void file_export_html_svg() { | |
| 252 | file_export( HTML_TEX_SVG ); | |
| 253 | } | |
| 254 | ||
| 255 | public void file_export_html_tex() { | |
| 256 | file_export( HTML_TEX_DELIMITED ); | |
| 257 | } | |
| 258 | ||
| 259 | public void file_export_xhtml_tex() { | |
| 260 | file_export( XHTML_TEX ); | |
| 261 | } | |
| 262 | ||
| 263 | public void file_export_markdown() { | |
| 264 | file_export( MARKDOWN_PLAIN ); | |
| 265 | } | |
| 266 | ||
| 267 | private void fireExportFailedEvent() { | |
| 268 | runLater( ExportFailedEvent::fire ); | |
| 269 | } | |
| 270 | ||
| 271 | public void file_exit() { | |
| 272 | final var window = getWindow(); | |
| 273 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 274 | } | |
| 275 | ||
| 276 | public void edit_undo() { | |
| 277 | getActiveTextEditor().undo(); | |
| 278 | } | |
| 279 | ||
| 280 | public void edit_redo() { | |
| 281 | getActiveTextEditor().redo(); | |
| 282 | } | |
| 283 | ||
| 284 | public void edit_cut() { | |
| 285 | getActiveTextEditor().cut(); | |
| 286 | } | |
| 287 | ||
| 288 | public void edit_copy() { | |
| 289 | getActiveTextEditor().copy(); | |
| 290 | } | |
| 291 | ||
| 292 | public void edit_paste() { | |
| 293 | getActiveTextEditor().paste(); | |
| 294 | } | |
| 295 | ||
| 296 | public void edit_select_all() { | |
| 297 | getActiveTextEditor().selectAll(); | |
| 298 | } | |
| 299 | ||
| 300 | public void edit_find() { | |
| 301 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 302 | ||
| 303 | if( nodes.isEmpty() ) { | |
| 304 | final var searchBar = new SearchBar(); | |
| 305 | ||
| 306 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 307 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 308 | ||
| 309 | searchBar.setOnCancelAction( ( event ) -> { | |
| 310 | final var editor = getActiveTextEditor(); | |
| 311 | nodes.remove( searchBar ); | |
| 312 | editor.unstylize( STYLE_SEARCH ); | |
| 313 | editor.getNode().requestFocus(); | |
| 314 | } ); | |
| 315 | ||
| 316 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 317 | if( n != null && !n.isEmpty() ) { | |
| 318 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 319 | } | |
| 320 | } ); | |
| 321 | ||
| 322 | searchBar.setOnNextAction( ( event ) -> edit_find_next() ); | |
| 323 | searchBar.setOnPrevAction( ( event ) -> edit_find_prev() ); | |
| 324 | ||
| 325 | nodes.add( searchBar ); | |
| 326 | searchBar.requestFocus(); | |
| 327 | } | |
| 328 | else { | |
| 329 | nodes.clear(); | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | public void edit_find_next() { | |
| 334 | mSearchModel.advance(); | |
| 335 | } | |
| 336 | ||
| 337 | public void edit_find_prev() { | |
| 338 | mSearchModel.retreat(); | |
| 339 | } | |
| 340 | ||
| 341 | public void edit_preferences() { | |
| 342 | try { | |
| 343 | new PreferencesController( getWorkspace() ).show(); | |
| 344 | } catch( final Exception ex ) { | |
| 345 | clue( ex ); | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | public void format_bold() { | |
| 350 | getActiveTextEditor().bold(); | |
| 351 | } | |
| 352 | ||
| 353 | public void format_italic() { | |
| 354 | getActiveTextEditor().italic(); | |
| 355 | } | |
| 356 | ||
| 357 | public void format_monospace() { | |
| 358 | getActiveTextEditor().monospace(); | |
| 359 | } | |
| 360 | ||
| 361 | public void format_superscript() { | |
| 362 | getActiveTextEditor().superscript(); | |
| 363 | } | |
| 364 | ||
| 365 | public void format_subscript() { | |
| 366 | getActiveTextEditor().subscript(); | |
| 367 | } | |
| 368 | ||
| 369 | public void format_strikethrough() { | |
| 370 | getActiveTextEditor().strikethrough(); | |
| 371 | } | |
| 372 | ||
| 373 | public void insert_blockquote() { | |
| 374 | getActiveTextEditor().blockquote(); | |
| 375 | } | |
| 376 | ||
| 377 | public void insert_code() { | |
| 378 | getActiveTextEditor().code(); | |
| 379 | } | |
| 380 | ||
| 381 | public void insert_fenced_code_block() { | |
| 382 | getActiveTextEditor().fencedCodeBlock(); | |
| 383 | } | |
| 384 | ||
| 385 | public void insert_link() { | |
| 386 | insertObject( createLinkDialog() ); | |
| 387 | } | |
| 388 | ||
| 389 | public void insert_image() { | |
| 390 | insertObject( createImageDialog() ); | |
| 391 | } | |
| 392 | ||
| 393 | private void insertObject( final Dialog<String> dialog ) { | |
| 394 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 395 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 396 | } | |
| 397 | ||
| 398 | private Dialog<String> createLinkDialog() { | |
| 399 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 400 | } | |
| 401 | ||
| 402 | private Dialog<String> createImageDialog() { | |
| 403 | final var path = getActiveTextEditor().getPath(); | |
| 404 | final var parentDir = path.getParent(); | |
| 405 | return new ImageDialog( getWindow(), parentDir ); | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 410 | * the Markdown AST. | |
| 411 | * | |
| 412 | * @return An instance containing the link URL and display text. | |
| 413 | */ | |
| 414 | private HyperlinkModel createHyperlinkModel() { | |
| 415 | final var context = getMainPane().createProcessorContext(); | |
| 416 | final var editor = getActiveTextEditor(); | |
| 417 | final var textArea = editor.getTextArea(); | |
| 418 | final var selectedText = textArea.getSelectedText(); | |
| 419 | ||
| 420 | // Convert current paragraph to Markdown nodes. | |
| 421 | final var mp = MarkdownProcessor.create( context ); | |
| 422 | final var p = textArea.getCurrentParagraph(); | |
| 423 | final var paragraph = textArea.getText( p ); | |
| 424 | final var node = mp.toNode( paragraph ); | |
| 425 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 426 | final var link = visitor.process( node ); | |
| 427 | ||
| 428 | if( link != null ) { | |
| 429 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 430 | } | |
| 431 | ||
| 432 | return createHyperlinkModel( link, selectedText ); | |
| 433 | } | |
| 434 | ||
| 435 | private HyperlinkModel createHyperlinkModel( | |
| 436 | final Link link, final String selection ) { | |
| 437 | ||
| 438 | return link == null | |
| 439 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 440 | : new HyperlinkModel( link ); | |
| 441 | } | |
| 442 | ||
| 443 | public void insert_heading_1() { | |
| 444 | insert_heading( 1 ); | |
| 445 | } | |
| 446 | ||
| 447 | public void insert_heading_2() { | |
| 448 | insert_heading( 2 ); | |
| 449 | } | |
| 450 | ||
| 451 | public void insert_heading_3() { | |
| 452 | insert_heading( 3 ); | |
| 453 | } | |
| 454 | ||
| 455 | private void insert_heading( final int level ) { | |
| 456 | getActiveTextEditor().heading( level ); | |
| 457 | } | |
| 458 | ||
| 459 | public void insert_unordered_list() { | |
| 460 | getActiveTextEditor().unorderedList(); | |
| 461 | } | |
| 462 | ||
| 463 | public void insert_ordered_list() { | |
| 464 | getActiveTextEditor().orderedList(); | |
| 465 | } | |
| 466 | ||
| 467 | public void insert_horizontal_rule() { | |
| 468 | getActiveTextEditor().horizontalRule(); | |
| 469 | } | |
| 470 | ||
| 471 | public void definition_create() { | |
| 472 | getActiveTextDefinition().createDefinition(); | |
| 473 | } | |
| 474 | ||
| 475 | public void definition_rename() { | |
| 476 | getActiveTextDefinition().renameDefinition(); | |
| 477 | } | |
| 478 | ||
| 479 | public void definition_delete() { | |
| 480 | getActiveTextDefinition().deleteDefinitions(); | |
| 481 | } | |
| 482 | ||
| 483 | public void definition_autoinsert() { | |
| 484 | getMainPane().autoinsert(); | |
| 485 | } | |
| 486 | ||
| 487 | public void view_refresh() { | |
| 488 | getMainPane().viewRefresh(); | |
| 489 | } | |
| 490 | ||
| 491 | public void view_preview() { | |
| 492 | getMainPane().viewPreview(); | |
| 493 | } | |
| 494 | ||
| 495 | public void view_outline() { | |
| 496 | getMainPane().viewOutline(); | |
| 497 | } | |
| 498 | ||
| 499 | public void view_files() {getMainPane().viewFiles();} | |
| 500 | ||
| 501 | public void view_statistics() { | |
| 502 | getMainPane().viewStatistics(); | |
| 503 | } | |
| 504 | ||
| 505 | public void view_menubar() { | |
| 506 | getMainScene().toggleMenuBar(); | |
| 507 | } | |
| 508 | ||
| 509 | public void view_toolbar() { | |
| 510 | getMainScene().toggleToolBar(); | |
| 511 | } | |
| 512 | ||
| 513 | public void view_statusbar() { | |
| 514 | getMainScene().toggleStatusBar(); | |
| 515 | } | |
| 516 | ||
| 517 | public void view_log() { | |
| 518 | mLogView.view(); | |
| 519 | } | |
| 520 | ||
| 521 | public void help_about() { | |
| 522 | final var alert = new Alert( INFORMATION ); | |
| 523 | final var prefix = "Dialog.about."; | |
| 524 | alert.setTitle( get( prefix + "title", APP_TITLE ) ); | |
| 525 | alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | |
| 526 | alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | |
| 527 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 528 | alert.initOwner( getWindow() ); | |
| 529 | alert.showAndWait(); | |
| 530 | } | |
| 531 | ||
| 532 | /** | |
| 533 | * Concatenates all the files in the same directory as the given file into | |
| 534 | * a string. The extension is determined by the given file name pattern; the | |
| 535 | * order files are concatenated is based on their numeric sort order (this | |
| 536 | * avoids lexicographic sorting). | |
| 537 | * <p> | |
| 538 | * If the parent path to the file being edited in the text editor cannot | |
| 539 | * be found then this will return the editor's text, without iterating through | |
| 540 | * the parent directory. (Should never happen, but who knows?) | |
| 541 | * </p> | |
| 542 | * <p> | |
| 543 | * New lines are automatically appended to separate each file. | |
| 544 | * </p> | |
| 545 | * | |
| 546 | * @param editor The text editor containing | |
| 547 | * @return All files in the same directory as the file being edited | |
| 548 | * concatenated into a single string. | |
| 549 | */ | |
| 550 | private String append( final TextEditor editor ) { | |
| 551 | final var pattern = editor.getPath(); | |
| 552 | final var parent = pattern.getParent(); | |
| 553 | ||
| 554 | // Short-circuit because nothing else can be done. | |
| 555 | if( parent == null ) { | |
| 556 | clue( "Main.status.export.concat.parent", pattern ); | |
| 557 | return editor.getText(); | |
| 558 | } | |
| 559 | ||
| 560 | final var filename = pattern.getFileName().toString(); | |
| 561 | final var extension = getExtension( filename ); | |
| 562 | ||
| 563 | if( extension.isBlank() ) { | |
| 564 | clue( "Main.status.export.concat.extension", filename ); | |
| 565 | return editor.getText(); | |
| 566 | } | |
| 567 | ||
| 568 | try { | |
| 569 | final var glob = "**/*." + extension; | |
| 570 | final ArrayList<Path> files = new ArrayList<>(); | |
| 571 | walk( parent, glob, files::add ); | |
| 572 | files.sort( new AlphanumComparator<>() ); | |
| 573 | ||
| 574 | final var text = new StringBuilder( DOCUMENT_LENGTH ); | |
| 575 | ||
| 576 | files.forEach( ( file ) -> { | |
| 577 | try { | |
| 578 | clue( "Main.status.export.concat", file ); | |
| 579 | text.append( readString( file ) ); | |
| 580 | } catch( final IOException ex ) { | |
| 581 | clue( "Main.status.export.concat.io", file ); | |
| 582 | } | |
| 583 | } ); | |
| 584 | ||
| 585 | return text.toString(); | |
| 586 | } catch( final Throwable t ) { | |
| 587 | clue( t ); | |
| 588 | return editor.getText(); | |
| 589 | } | |
| 590 | } | |
| 591 | ||
| 592 | private Optional<List<File>> pickFiles( final Options... options ) { | |
| 593 | return createPicker( options ).choose(); | |
| 594 | } | |
| 595 | ||
| 596 | private Optional<List<File>> pickFiles( | |
| 597 | final File filename, final Options... options ) { | |
| 598 | final var picker = createPicker( options ); | |
| 599 | picker.setInitialFilename( filename ); | |
| 600 | return picker.choose(); | |
| 601 | } | |
| 602 | ||
| 603 | private FilePicker createPicker( final Options... options ) { | |
| 604 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 605 | return factory.createModal( getWindow(), options ); | |
| 606 | } | |
| 607 | ||
| 608 | private TextEditor getActiveTextEditor() { | |
| 609 | return getMainPane().getActiveTextEditor(); | |
| 610 | } | |
| 611 | ||
| 612 | private TextDefinition getActiveTextDefinition() { | |
| 613 | return getMainPane().getActiveTextDefinition(); | |
| 46 | import static com.keenwrite.preferences.AppKeys.*; | |
| 47 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 48 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options; | |
| 49 | import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*; | |
| 50 | import static com.keenwrite.util.FileWalker.walk; | |
| 51 | import static java.nio.file.Files.readString; | |
| 52 | import static java.nio.file.Files.writeString; | |
| 53 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 54 | import static javafx.application.Platform.runLater; | |
| 55 | import static javafx.event.Event.fireEvent; | |
| 56 | import static javafx.scene.control.Alert.AlertType.INFORMATION; | |
| 57 | import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | |
| 58 | import static org.apache.commons.io.FilenameUtils.getExtension; | |
| 59 | ||
| 60 | /** | |
| 61 | * Responsible for abstracting how functionality is mapped to the application. | |
| 62 | * This allows users to customize accelerator keys and will provide pluggable | |
| 63 | * functionality so that different text markup languages can change documents | |
| 64 | * using their respective syntax. | |
| 65 | */ | |
| 66 | public final class GuiCommands { | |
| 67 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 68 | ||
| 69 | private static final String STYLE_SEARCH = "search"; | |
| 70 | ||
| 71 | /** | |
| 72 | * Sci-fi genres, which are can be longer than other genres, typically fall | |
| 73 | * below 150,000 words at 6 chars per word. This reduces re-allocations of | |
| 74 | * memory when concatenating files together when exporting novels. | |
| 75 | */ | |
| 76 | private static final int DOCUMENT_LENGTH = 150_000 * 6; | |
| 77 | ||
| 78 | /** | |
| 79 | * When an action is executed, this is one of the recipients. | |
| 80 | */ | |
| 81 | private final MainPane mMainPane; | |
| 82 | ||
| 83 | private final MainScene mMainScene; | |
| 84 | ||
| 85 | private final LogView mLogView; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks finding text in the active document. | |
| 89 | */ | |
| 90 | private final SearchModel mSearchModel; | |
| 91 | ||
| 92 | public GuiCommands( final MainScene scene, final MainPane pane ) { | |
| 93 | mMainScene = scene; | |
| 94 | mMainPane = pane; | |
| 95 | mLogView = new LogView(); | |
| 96 | mSearchModel = new SearchModel(); | |
| 97 | mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | |
| 98 | final var editor = getActiveTextEditor(); | |
| 99 | ||
| 100 | // Clear highlighted areas before highlighting a new region. | |
| 101 | if( o != null ) { | |
| 102 | editor.unstylize( STYLE_SEARCH ); | |
| 103 | } | |
| 104 | ||
| 105 | if( n != null ) { | |
| 106 | editor.moveTo( n.getStart() ); | |
| 107 | editor.stylize( n, STYLE_SEARCH ); | |
| 108 | } | |
| 109 | } ); | |
| 110 | ||
| 111 | // When the active text editor changes, update the haystack. | |
| 112 | mMainPane.textEditorProperty().addListener( | |
| 113 | ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | |
| 114 | ); | |
| 115 | } | |
| 116 | ||
| 117 | public void file_new() { | |
| 118 | getMainPane().newTextEditor(); | |
| 119 | } | |
| 120 | ||
| 121 | public void file_open() { | |
| 122 | pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | |
| 123 | } | |
| 124 | ||
| 125 | public void file_close() { | |
| 126 | getMainPane().close(); | |
| 127 | } | |
| 128 | ||
| 129 | public void file_close_all() { | |
| 130 | getMainPane().closeAll(); | |
| 131 | } | |
| 132 | ||
| 133 | public void file_save() { | |
| 134 | getMainPane().save(); | |
| 135 | } | |
| 136 | ||
| 137 | public void file_save_as() { | |
| 138 | pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | |
| 139 | } | |
| 140 | ||
| 141 | public void file_save_all() { | |
| 142 | getMainPane().saveAll(); | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Converts the actively edited file in the given file format. | |
| 147 | * | |
| 148 | * @param format The destination file format. | |
| 149 | */ | |
| 150 | private void file_export( final ExportFormat format ) { | |
| 151 | file_export( format, false ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Converts one or more files into the given file format. If {@code dir} | |
| 156 | * is set to true, this will first append all files in the same directory | |
| 157 | * as the actively edited file. | |
| 158 | * | |
| 159 | * @param format The destination file format. | |
| 160 | * @param dir Export all files in the actively edited file's directory. | |
| 161 | */ | |
| 162 | private void file_export( final ExportFormat format, final boolean dir ) { | |
| 163 | final var main = getMainPane(); | |
| 164 | final var editor = main.getTextEditor(); | |
| 165 | final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT ); | |
| 166 | final var filename = format.toExportFilename( editor.getPath() ); | |
| 167 | final var selection = pickFiles( | |
| 168 | Constants.PDF_DEFAULT.getName().equals( exported.get().getName() ) | |
| 169 | ? filename | |
| 170 | : exported.get(), FILE_EXPORT | |
| 171 | ); | |
| 172 | ||
| 173 | selection.ifPresent( ( files ) -> { | |
| 174 | editor.save(); | |
| 175 | ||
| 176 | final var file = files.get( 0 ); | |
| 177 | final var path = file.toPath(); | |
| 178 | final var document = dir ? append( editor ) : editor.getText(); | |
| 179 | final var context = main.createProcessorContext( path, format ); | |
| 180 | ||
| 181 | final var task = new Task<Path>() { | |
| 182 | @Override | |
| 183 | protected Path call() throws Exception { | |
| 184 | final var chain = createProcessors( context ); | |
| 185 | final var export = chain.apply( document ); | |
| 186 | ||
| 187 | // Processors can export binary files. In such cases, processors | |
| 188 | // return null to prevent further processing. | |
| 189 | return export == null ? null : writeString( path, export ); | |
| 190 | } | |
| 191 | }; | |
| 192 | ||
| 193 | task.setOnSucceeded( | |
| 194 | e -> { | |
| 195 | // Remember the exported file name for next time. | |
| 196 | exported.setValue( file ); | |
| 197 | ||
| 198 | final var result = task.getValue(); | |
| 199 | ||
| 200 | // Binary formats must notify users of success independently. | |
| 201 | if( result != null ) { | |
| 202 | clue( "Main.status.export.success", result ); | |
| 203 | } | |
| 204 | } | |
| 205 | ); | |
| 206 | ||
| 207 | task.setOnFailed( e -> { | |
| 208 | final var ex = task.getException(); | |
| 209 | clue( ex ); | |
| 210 | ||
| 211 | if( ex instanceof TypeNotPresentException ) { | |
| 212 | fireExportFailedEvent(); | |
| 213 | } | |
| 214 | } ); | |
| 215 | ||
| 216 | sExecutor.execute( task ); | |
| 217 | } ); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * @param dir {@code true} means to export all files in the active file | |
| 222 | * editor's directory; {@code false} means to export only the | |
| 223 | * actively edited file. | |
| 224 | */ | |
| 225 | private void file_export_pdf( final boolean dir ) { | |
| 226 | final var workspace = getWorkspace(); | |
| 227 | final var themes = workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | |
| 228 | final var theme = workspace.stringProperty( | |
| 229 | KEY_TYPESET_CONTEXT_THEME_SELECTION ); | |
| 230 | ||
| 231 | if( Typesetter.canRun() ) { | |
| 232 | // If the typesetter is installed, allow the user to select a theme. If | |
| 233 | // the themes aren't installed, a status message will appear. | |
| 234 | if( ThemePicker.choose( themes, theme ) ) { | |
| 235 | file_export( APPLICATION_PDF, dir ); | |
| 236 | } | |
| 237 | } | |
| 238 | else { | |
| 239 | fireExportFailedEvent(); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | public void file_export_pdf() { | |
| 244 | file_export_pdf( false ); | |
| 245 | } | |
| 246 | ||
| 247 | public void file_export_pdf_dir() { | |
| 248 | file_export_pdf( true ); | |
| 249 | } | |
| 250 | ||
| 251 | public void file_export_html_svg() { | |
| 252 | file_export( HTML_TEX_SVG ); | |
| 253 | } | |
| 254 | ||
| 255 | public void file_export_html_tex() { | |
| 256 | file_export( HTML_TEX_DELIMITED ); | |
| 257 | } | |
| 258 | ||
| 259 | public void file_export_xhtml_tex() { | |
| 260 | file_export( XHTML_TEX ); | |
| 261 | } | |
| 262 | ||
| 263 | public void file_export_markdown() { | |
| 264 | file_export( MARKDOWN_PLAIN ); | |
| 265 | } | |
| 266 | ||
| 267 | private void fireExportFailedEvent() { | |
| 268 | runLater( ExportFailedEvent::fire ); | |
| 269 | } | |
| 270 | ||
| 271 | public void file_exit() { | |
| 272 | final var window = getWindow(); | |
| 273 | fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | |
| 274 | } | |
| 275 | ||
| 276 | public void edit_undo() { | |
| 277 | getActiveTextEditor().undo(); | |
| 278 | } | |
| 279 | ||
| 280 | public void edit_redo() { | |
| 281 | getActiveTextEditor().redo(); | |
| 282 | } | |
| 283 | ||
| 284 | public void edit_cut() { | |
| 285 | getActiveTextEditor().cut(); | |
| 286 | } | |
| 287 | ||
| 288 | public void edit_copy() { | |
| 289 | getActiveTextEditor().copy(); | |
| 290 | } | |
| 291 | ||
| 292 | public void edit_paste() { | |
| 293 | getActiveTextEditor().paste(); | |
| 294 | } | |
| 295 | ||
| 296 | public void edit_select_all() { | |
| 297 | getActiveTextEditor().selectAll(); | |
| 298 | } | |
| 299 | ||
| 300 | public void edit_find() { | |
| 301 | final var nodes = getMainScene().getStatusBar().getLeftItems(); | |
| 302 | ||
| 303 | if( nodes.isEmpty() ) { | |
| 304 | final var searchBar = new SearchBar(); | |
| 305 | ||
| 306 | searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | |
| 307 | searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | |
| 308 | ||
| 309 | searchBar.setOnCancelAction( ( event ) -> { | |
| 310 | final var editor = getActiveTextEditor(); | |
| 311 | nodes.remove( searchBar ); | |
| 312 | editor.unstylize( STYLE_SEARCH ); | |
| 313 | editor.getNode().requestFocus(); | |
| 314 | } ); | |
| 315 | ||
| 316 | searchBar.addInputListener( ( c, o, n ) -> { | |
| 317 | if( n != null && !n.isEmpty() ) { | |
| 318 | mSearchModel.search( n, getActiveTextEditor().getText() ); | |
| 319 | } | |
| 320 | } ); | |
| 321 | ||
| 322 | searchBar.setOnNextAction( ( event ) -> edit_find_next() ); | |
| 323 | searchBar.setOnPrevAction( ( event ) -> edit_find_prev() ); | |
| 324 | ||
| 325 | nodes.add( searchBar ); | |
| 326 | searchBar.requestFocus(); | |
| 327 | } | |
| 328 | else { | |
| 329 | nodes.clear(); | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | public void edit_find_next() { | |
| 334 | mSearchModel.advance(); | |
| 335 | } | |
| 336 | ||
| 337 | public void edit_find_prev() { | |
| 338 | mSearchModel.retreat(); | |
| 339 | } | |
| 340 | ||
| 341 | public void edit_preferences() { | |
| 342 | try { | |
| 343 | new PreferencesController( getWorkspace() ).show(); | |
| 344 | } catch( final Exception ex ) { | |
| 345 | clue( ex ); | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | public void format_bold() { | |
| 350 | getActiveTextEditor().bold(); | |
| 351 | } | |
| 352 | ||
| 353 | public void format_italic() { | |
| 354 | getActiveTextEditor().italic(); | |
| 355 | } | |
| 356 | ||
| 357 | public void format_monospace() { | |
| 358 | getActiveTextEditor().monospace(); | |
| 359 | } | |
| 360 | ||
| 361 | public void format_superscript() { | |
| 362 | getActiveTextEditor().superscript(); | |
| 363 | } | |
| 364 | ||
| 365 | public void format_subscript() { | |
| 366 | getActiveTextEditor().subscript(); | |
| 367 | } | |
| 368 | ||
| 369 | public void format_strikethrough() { | |
| 370 | getActiveTextEditor().strikethrough(); | |
| 371 | } | |
| 372 | ||
| 373 | public void insert_blockquote() { | |
| 374 | getActiveTextEditor().blockquote(); | |
| 375 | } | |
| 376 | ||
| 377 | public void insert_code() { | |
| 378 | getActiveTextEditor().code(); | |
| 379 | } | |
| 380 | ||
| 381 | public void insert_fenced_code_block() { | |
| 382 | getActiveTextEditor().fencedCodeBlock(); | |
| 383 | } | |
| 384 | ||
| 385 | public void insert_link() { | |
| 386 | insertObject( createLinkDialog() ); | |
| 387 | } | |
| 388 | ||
| 389 | public void insert_image() { | |
| 390 | insertObject( createImageDialog() ); | |
| 391 | } | |
| 392 | ||
| 393 | private void insertObject( final Dialog<String> dialog ) { | |
| 394 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 395 | dialog.showAndWait().ifPresent( textArea::replaceSelection ); | |
| 396 | } | |
| 397 | ||
| 398 | private Dialog<String> createLinkDialog() { | |
| 399 | return new LinkDialog( getWindow(), createHyperlinkModel() ); | |
| 400 | } | |
| 401 | ||
| 402 | private Dialog<String> createImageDialog() { | |
| 403 | final var path = getActiveTextEditor().getPath(); | |
| 404 | final var parentDir = path.getParent(); | |
| 405 | return new ImageDialog( getWindow(), parentDir ); | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * Returns one of: selected text, word under cursor, or parsed hyperlink from | |
| 410 | * the Markdown AST. | |
| 411 | * | |
| 412 | * @return An instance containing the link URL and display text. | |
| 413 | */ | |
| 414 | private HyperlinkModel createHyperlinkModel() { | |
| 415 | final var context = getMainPane().createProcessorContext(); | |
| 416 | final var editor = getActiveTextEditor(); | |
| 417 | final var textArea = editor.getTextArea(); | |
| 418 | final var selectedText = textArea.getSelectedText(); | |
| 419 | ||
| 420 | // Convert current paragraph to Markdown nodes. | |
| 421 | final var mp = MarkdownProcessor.create( context ); | |
| 422 | final var p = textArea.getCurrentParagraph(); | |
| 423 | final var paragraph = textArea.getText( p ); | |
| 424 | final var node = mp.toNode( paragraph ); | |
| 425 | final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | |
| 426 | final var link = visitor.process( node ); | |
| 427 | ||
| 428 | if( link != null ) { | |
| 429 | textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | |
| 430 | } | |
| 431 | ||
| 432 | return createHyperlinkModel( link, selectedText ); | |
| 433 | } | |
| 434 | ||
| 435 | private HyperlinkModel createHyperlinkModel( | |
| 436 | final Link link, final String selection ) { | |
| 437 | ||
| 438 | return link == null | |
| 439 | ? new HyperlinkModel( selection, "https://localhost" ) | |
| 440 | : new HyperlinkModel( link ); | |
| 441 | } | |
| 442 | ||
| 443 | public void insert_heading_1() { | |
| 444 | insert_heading( 1 ); | |
| 445 | } | |
| 446 | ||
| 447 | public void insert_heading_2() { | |
| 448 | insert_heading( 2 ); | |
| 449 | } | |
| 450 | ||
| 451 | public void insert_heading_3() { | |
| 452 | insert_heading( 3 ); | |
| 453 | } | |
| 454 | ||
| 455 | private void insert_heading( final int level ) { | |
| 456 | getActiveTextEditor().heading( level ); | |
| 457 | } | |
| 458 | ||
| 459 | public void insert_unordered_list() { | |
| 460 | getActiveTextEditor().unorderedList(); | |
| 461 | } | |
| 462 | ||
| 463 | public void insert_ordered_list() { | |
| 464 | getActiveTextEditor().orderedList(); | |
| 465 | } | |
| 466 | ||
| 467 | public void insert_horizontal_rule() { | |
| 468 | getActiveTextEditor().horizontalRule(); | |
| 469 | } | |
| 470 | ||
| 471 | public void definition_create() { | |
| 472 | getActiveTextDefinition().createDefinition(); | |
| 473 | } | |
| 474 | ||
| 475 | public void definition_rename() { | |
| 476 | getActiveTextDefinition().renameDefinition(); | |
| 477 | } | |
| 478 | ||
| 479 | public void definition_delete() { | |
| 480 | getActiveTextDefinition().deleteDefinitions(); | |
| 481 | } | |
| 482 | ||
| 483 | public void definition_autoinsert() { | |
| 484 | getMainPane().autoinsert(); | |
| 485 | } | |
| 486 | ||
| 487 | public void view_refresh() { | |
| 488 | getMainPane().viewRefresh(); | |
| 489 | } | |
| 490 | ||
| 491 | public void view_preview() { | |
| 492 | getMainPane().viewPreview(); | |
| 493 | } | |
| 494 | ||
| 495 | public void view_outline() { | |
| 496 | getMainPane().viewOutline(); | |
| 497 | } | |
| 498 | ||
| 499 | public void view_files() {getMainPane().viewFiles();} | |
| 500 | ||
| 501 | public void view_statistics() { | |
| 502 | getMainPane().viewStatistics(); | |
| 503 | } | |
| 504 | ||
| 505 | public void view_menubar() { | |
| 506 | getMainScene().toggleMenuBar(); | |
| 507 | } | |
| 508 | ||
| 509 | public void view_toolbar() { | |
| 510 | getMainScene().toggleToolBar(); | |
| 511 | } | |
| 512 | ||
| 513 | public void view_statusbar() { | |
| 514 | getMainScene().toggleStatusBar(); | |
| 515 | } | |
| 516 | ||
| 517 | public void view_log() { | |
| 518 | mLogView.view(); | |
| 519 | } | |
| 520 | ||
| 521 | public void help_about() { | |
| 522 | final var alert = new Alert( INFORMATION ); | |
| 523 | final var prefix = "Dialog.about."; | |
| 524 | alert.setTitle( get( prefix + "title", APP_TITLE ) ); | |
| 525 | alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | |
| 526 | alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | |
| 527 | alert.setGraphic( ICON_DIALOG_NODE ); | |
| 528 | alert.initOwner( getWindow() ); | |
| 529 | alert.showAndWait(); | |
| 530 | } | |
| 531 | ||
| 532 | /** | |
| 533 | * Concatenates all the files in the same directory as the given file into | |
| 534 | * a string. The extension is determined by the given file name pattern; the | |
| 535 | * order files are concatenated is based on their numeric sort order (this | |
| 536 | * avoids lexicographic sorting). | |
| 537 | * <p> | |
| 538 | * If the parent path to the file being edited in the text editor cannot | |
| 539 | * be found then this will return the editor's text, without iterating through | |
| 540 | * the parent directory. (Should never happen, but who knows?) | |
| 541 | * </p> | |
| 542 | * <p> | |
| 543 | * New lines are automatically appended to separate each file. | |
| 544 | * </p> | |
| 545 | * | |
| 546 | * @param editor The text editor containing | |
| 547 | * @return All files in the same directory as the file being edited | |
| 548 | * concatenated into a single string. | |
| 549 | */ | |
| 550 | private String append( final TextEditor editor ) { | |
| 551 | final var pattern = editor.getPath(); | |
| 552 | final var parent = pattern.getParent(); | |
| 553 | ||
| 554 | // Short-circuit because nothing else can be done. | |
| 555 | if( parent == null ) { | |
| 556 | clue( "Main.status.export.concat.parent", pattern ); | |
| 557 | return editor.getText(); | |
| 558 | } | |
| 559 | ||
| 560 | final var filename = pattern.getFileName().toString(); | |
| 561 | final var extension = getExtension( filename ); | |
| 562 | ||
| 563 | if( extension.isBlank() ) { | |
| 564 | clue( "Main.status.export.concat.extension", filename ); | |
| 565 | return editor.getText(); | |
| 566 | } | |
| 567 | ||
| 568 | try { | |
| 569 | final var glob = "**/*." + extension; | |
| 570 | final ArrayList<Path> files = new ArrayList<>(); | |
| 571 | walk( parent, glob, files::add ); | |
| 572 | files.sort( new AlphanumComparator<>() ); | |
| 573 | ||
| 574 | final var text = new StringBuilder( DOCUMENT_LENGTH ); | |
| 575 | ||
| 576 | files.forEach( file -> { | |
| 577 | try { | |
| 578 | clue( "Main.status.export.concat", file ); | |
| 579 | text.append( readString( file ) ); | |
| 580 | } catch( final IOException ex ) { | |
| 581 | clue( "Main.status.export.concat.io", file ); | |
| 582 | } | |
| 583 | } ); | |
| 584 | ||
| 585 | return text.toString(); | |
| 586 | } catch( final Throwable t ) { | |
| 587 | clue( t ); | |
| 588 | return editor.getText(); | |
| 589 | } | |
| 590 | } | |
| 591 | ||
| 592 | private Optional<List<File>> pickFiles( final Options... options ) { | |
| 593 | return createPicker( options ).choose(); | |
| 594 | } | |
| 595 | ||
| 596 | private Optional<List<File>> pickFiles( | |
| 597 | final File filename, final Options... options ) { | |
| 598 | final var picker = createPicker( options ); | |
| 599 | picker.setInitialFilename( filename ); | |
| 600 | return picker.choose(); | |
| 601 | } | |
| 602 | ||
| 603 | private FilePicker createPicker( final Options... options ) { | |
| 604 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 605 | return factory.createModal( getWindow(), options ); | |
| 606 | } | |
| 607 | ||
| 608 | private TextEditor getActiveTextEditor() { | |
| 609 | return getMainPane().getTextEditor(); | |
| 610 | } | |
| 611 | ||
| 612 | private TextDefinition getActiveTextDefinition() { | |
| 613 | return getMainPane().getTextDefinition(); | |
| 614 | 614 | } |
| 615 | 615 |
| 1 | package com.keenwrite.ui.common; | |
| 2 | ||
| 3 | import javafx.beans.property.ObjectProperty; | |
| 4 | import javafx.beans.property.Property; | |
| 5 | import javafx.beans.property.SimpleStringProperty; | |
| 6 | import javafx.beans.value.ChangeListener; | |
| 7 | import javafx.beans.value.ObservableValue; | |
| 8 | import javafx.event.EventHandler; | |
| 9 | import javafx.scene.Node; | |
| 10 | import javafx.scene.control.TableCell; | |
| 11 | import javafx.scene.control.TextField; | |
| 12 | import javafx.scene.control.TreeCell; | |
| 13 | import javafx.scene.input.KeyEvent; | |
| 14 | ||
| 15 | import java.util.function.Consumer; | |
| 16 | ||
| 17 | import static javafx.application.Platform.runLater; | |
| 18 | import static javafx.scene.input.KeyCode.ENTER; | |
| 19 | import static javafx.scene.input.KeyCode.TAB; | |
| 20 | import static javafx.scene.input.KeyEvent.KEY_RELEASED; | |
| 21 | ||
| 22 | public class CellEditor { | |
| 23 | private FocusListener mFocusListener; | |
| 24 | private final KeyHandler mKeyHandler = new KeyHandler(); | |
| 25 | private final Property<String> mInputText = new SimpleStringProperty(); | |
| 26 | private final Consumer<String> mConsumer; | |
| 27 | ||
| 28 | /** | |
| 29 | * Responsible for accepting the text when users press the Enter or Tab key. | |
| 30 | */ | |
| 31 | private class KeyHandler implements EventHandler<KeyEvent> { | |
| 32 | @Override | |
| 33 | public void handle( final KeyEvent event ) { | |
| 34 | if( event.getCode() == ENTER || event.getCode() == TAB ) { | |
| 35 | commitEdit(); | |
| 36 | event.consume(); | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /** | |
| 42 | * Responsible for committing edits when focus is lost. This will also | |
| 43 | * deselect the input field when focus is gained so that typing text won't | |
| 44 | * overwrite the entire existing text. | |
| 45 | */ | |
| 46 | private class FocusListener implements ChangeListener<Boolean> { | |
| 47 | private final TextField mInput; | |
| 48 | ||
| 49 | private FocusListener( final TextField input ) { | |
| 50 | mInput = input; | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | public void changed( | |
| 55 | final ObservableValue<? extends Boolean> c, | |
| 56 | final Boolean endedFocus, final Boolean beganFocus ) { | |
| 57 | ||
| 58 | if( beganFocus ) { | |
| 59 | runLater( mInput::deselect ); | |
| 60 | } | |
| 61 | else if( endedFocus ) { | |
| 62 | commitEdit(); | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Generalized cell editor suitable for use with {@link TableCell} or | |
| 69 | * {@link TreeCell} instances. | |
| 70 | * | |
| 71 | * @param consumer Converts the field input text to the required | |
| 72 | * data type. | |
| 73 | * @param graphicProperty Defines the graphical user input field. | |
| 74 | */ | |
| 75 | public CellEditor( | |
| 76 | final Consumer<String> consumer, | |
| 77 | final ObjectProperty<Node> graphicProperty ) { | |
| 78 | assert consumer != null; | |
| 79 | mConsumer = consumer; | |
| 80 | ||
| 81 | init( graphicProperty ); | |
| 82 | } | |
| 83 | ||
| 84 | private void init( final ObjectProperty<Node> graphicProperty ) { | |
| 85 | // When the text field is added as the graphics context, we hook into | |
| 86 | // the changed value to get a handle on the text field. From there it is | |
| 87 | // possible to add change the keyboard and focus behaviours. | |
| 88 | graphicProperty.addListener( ( c, o, n ) -> { | |
| 89 | if( o instanceof TextField ) { | |
| 90 | o.removeEventHandler( KEY_RELEASED, mKeyHandler ); | |
| 91 | o.focusedProperty().removeListener( mFocusListener ); | |
| 92 | } | |
| 93 | ||
| 94 | if( n instanceof final TextField input ) { | |
| 95 | n.addEventFilter( KEY_RELEASED, mKeyHandler ); | |
| 96 | mInputText.bind( input.textProperty() ); | |
| 97 | mFocusListener = new FocusListener( input ); | |
| 98 | n.focusedProperty().addListener( mFocusListener ); | |
| 99 | } | |
| 100 | } ); | |
| 101 | } | |
| 102 | ||
| 103 | private void commitEdit() { | |
| 104 | mConsumer.accept( mInputText.getValue() ); | |
| 105 | } | |
| 106 | } | |
| 1 | 107 |
| 22 | 22 | import static com.keenwrite.constants.Constants.USER_DIRECTORY; |
| 23 | 23 | import static com.keenwrite.events.StatusEvent.clue; |
| 24 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_RECENT_DIR; | |
| 24 | import static com.keenwrite.preferences.AppKeys.KEY_UI_RECENT_DIR; | |
| 25 | 25 | import static java.nio.file.FileSystems.getDefault; |
| 26 | 26 | import static java.util.Optional.ofNullable; |
| 49 | 49 | |
| 50 | 50 | /** |
| 51 | * Prevent instantiation. Use the {@link #createGraphic(String)} method to | |
| 52 | * create an icon for display. | |
| 53 | */ | |
| 54 | private IconFactory() {} | |
| 55 | ||
| 56 | /** | |
| 57 | 51 | * Create a {@link Node} representation for the given icon name. |
| 58 | 52 | * |
| ... | ||
| 190 | 184 | return createGraphic( valueOf( icon.toUpperCase() ) ); |
| 191 | 185 | } |
| 186 | ||
| 187 | /** | |
| 188 | * Prevent instantiation. Use the {@link #createGraphic(String)} method to | |
| 189 | * create an icon for display. | |
| 190 | */ | |
| 191 | private IconFactory() {} | |
| 192 | 192 | } |
| 193 | 193 | |
| 18 | 18 | import static com.keenwrite.events.Bus.register; |
| 19 | 19 | import static com.keenwrite.events.StatusEvent.clue; |
| 20 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_LANGUAGE_LOCALE; | |
| 21 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_FONT_EDITOR_NAME; | |
| 20 | import static com.keenwrite.preferences.AppKeys.KEY_LANGUAGE_LOCALE; | |
| 21 | import static com.keenwrite.preferences.AppKeys.KEY_UI_FONT_EDITOR_NAME; | |
| 22 | 22 | import static com.keenwrite.ui.heuristics.DocumentStatistics.StatEntry; |
| 23 | 23 | import static java.lang.String.format; |
| 1 | package com.keenwrite.ui.table; | |
| 2 | ||
| 3 | import com.keenwrite.ui.common.CellEditor; | |
| 4 | import javafx.scene.control.cell.TextFieldTableCell; | |
| 5 | import javafx.util.StringConverter; | |
| 6 | ||
| 7 | public class AltTableCell<S, T> extends TextFieldTableCell<S, T> { | |
| 8 | public AltTableCell( final StringConverter<T> converter ) { | |
| 9 | super( converter ); | |
| 10 | ||
| 11 | assert converter != null; | |
| 12 | ||
| 13 | new CellEditor( | |
| 14 | input -> commitEdit( getConverter().fromString( input ) ), | |
| 15 | graphicProperty() | |
| 16 | ); | |
| 17 | } | |
| 18 | } | |
| 1 | 19 |
| 2 | 2 | package com.keenwrite.ui.tree; |
| 3 | 3 | |
| 4 | import javafx.beans.property.Property; | |
| 5 | import javafx.beans.property.SimpleStringProperty; | |
| 6 | import javafx.beans.value.ChangeListener; | |
| 7 | import javafx.beans.value.ObservableValue; | |
| 8 | import javafx.event.EventHandler; | |
| 9 | import javafx.scene.control.TextField; | |
| 4 | import com.keenwrite.ui.common.CellEditor; | |
| 10 | 5 | import javafx.scene.control.cell.TextFieldTreeCell; |
| 11 | import javafx.scene.input.KeyEvent; | |
| 12 | 6 | import javafx.util.StringConverter; |
| 13 | ||
| 14 | import static javafx.application.Platform.runLater; | |
| 15 | import static javafx.scene.input.KeyCode.ENTER; | |
| 16 | import static javafx.scene.input.KeyCode.TAB; | |
| 17 | import static javafx.scene.input.KeyEvent.KEY_RELEASED; | |
| 18 | 7 | |
| 19 | 8 | /** |
| 20 | 9 | * Responsible for enhancing the existing cell behaviour with fairly common |
| 21 | 10 | * functionality, including commit on focus loss and Enter to commit. |
| 22 | 11 | * |
| 23 | 12 | * @param <T> The type of data stored by the tree. |
| 24 | 13 | */ |
| 25 | 14 | public class AltTreeCell<T> extends TextFieldTreeCell<T> { |
| 26 | private final KeyHandler mKeyHandler = new KeyHandler(); | |
| 27 | private final Property<String> mInputText = new SimpleStringProperty(); | |
| 28 | private FocusListener mFocusListener; | |
| 29 | ||
| 30 | 15 | public AltTreeCell( final StringConverter<T> converter ) { |
| 31 | 16 | super( converter ); |
| 32 | assert converter != null; | |
| 33 | ||
| 34 | // When the text field is added as the graphics context, we hook into | |
| 35 | // the changed value to get a handle on the text field. From there it is | |
| 36 | // possible to add change the keyboard and focus behaviours. | |
| 37 | graphicProperty().addListener( ( c, o, n ) -> { | |
| 38 | if( o instanceof TextField ) { | |
| 39 | o.removeEventHandler( KEY_RELEASED, mKeyHandler ); | |
| 40 | o.focusedProperty().removeListener( mFocusListener ); | |
| 41 | } | |
| 42 | ||
| 43 | if( n instanceof final TextField input ) { | |
| 44 | n.addEventFilter( KEY_RELEASED, mKeyHandler ); | |
| 45 | mInputText.bind( input.textProperty() ); | |
| 46 | mFocusListener = new FocusListener( input ); | |
| 47 | n.focusedProperty().addListener( mFocusListener ); | |
| 48 | } | |
| 49 | } ); | |
| 50 | } | |
| 51 | ||
| 52 | private void commitEdit() { | |
| 53 | commitEdit( getConverter().fromString( mInputText.getValue() ) ); | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Responsible for accepting the text when users press the Enter or Tab key. | |
| 58 | */ | |
| 59 | private class KeyHandler implements EventHandler<KeyEvent> { | |
| 60 | @Override | |
| 61 | public void handle( final KeyEvent event ) { | |
| 62 | if( event.getCode() == ENTER || event.getCode() == TAB ) { | |
| 63 | commitEdit(); | |
| 64 | event.consume(); | |
| 65 | } | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Responsible for committing edits when focus is lost. This will also | |
| 71 | * deselect the input field when focus is gained so that typing text won't | |
| 72 | * overwrite the entire existing text. | |
| 73 | */ | |
| 74 | private class FocusListener implements ChangeListener<Boolean> { | |
| 75 | private final TextField mInput; | |
| 76 | ||
| 77 | private FocusListener( final TextField input ) { | |
| 78 | mInput = input; | |
| 79 | } | |
| 80 | 17 | |
| 81 | @Override | |
| 82 | public void changed( | |
| 83 | final ObservableValue<? extends Boolean> c, | |
| 84 | final Boolean endedFocus, final Boolean beganFocus ) { | |
| 18 | assert converter != null; | |
| 85 | 19 | |
| 86 | if( beganFocus ) { | |
| 87 | runLater( mInput::deselect ); | |
| 88 | } | |
| 89 | else if( endedFocus ) { | |
| 90 | commitEdit(); | |
| 91 | } | |
| 92 | } | |
| 20 | new CellEditor( | |
| 21 | input -> commitEdit( getConverter().fromString( input ) ), | |
| 22 | graphicProperty() | |
| 23 | ); | |
| 93 | 24 | } |
| 94 | 25 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.tree; | |
| 3 | ||
| 4 | import javafx.scene.control.TreeCell; | |
| 5 | import javafx.scene.control.TreeView; | |
| 6 | import javafx.util.Callback; | |
| 7 | import javafx.util.StringConverter; | |
| 8 | ||
| 9 | /** | |
| 10 | * Responsible for creating new {@link TreeCell} instances. | |
| 11 | * <p> | |
| 12 | * TODO: #22 -- Upon refactoring variable functionality, re-instate drag & drop. | |
| 13 | * </p> | |
| 14 | * | |
| 15 | * @param <T> The data type stored in the tree. | |
| 16 | */ | |
| 17 | public class AltTreeCellFactory<T> | |
| 18 | implements Callback<TreeView<T>, TreeCell<T>> { | |
| 19 | private final StringConverter<T> mConverter; | |
| 20 | ||
| 21 | public AltTreeCellFactory( final StringConverter<T> converter ) { | |
| 22 | mConverter = converter; | |
| 23 | } | |
| 24 | ||
| 25 | @Override | |
| 26 | public TreeCell<T> call( final TreeView<T> treeView ) { | |
| 27 | return new AltTreeCell<>( mConverter ); | |
| 28 | } | |
| 29 | } | |
| 30 | 1 |
| 19 | 19 | super( root ); |
| 20 | 20 | |
| 21 | assert root != null; | |
| 22 | assert converter != null; | |
| 23 | ||
| 21 | 24 | setEditable( true ); |
| 22 | setCellFactory( new AltTreeCellFactory<>( converter ) ); | |
| 25 | setCellFactory( treeView -> new AltTreeCell<>( converter ) ); | |
| 23 | 26 | setShowRoot( false ); |
| 24 | 27 | |
| 25 | // When focus is lost, clear the selected item only when not editing. | |
| 28 | // When focus is lost while not editing, deselect all items. | |
| 26 | 29 | focusedProperty().addListener( ( c, o, n ) -> { |
| 27 | 30 | if( o && getEditingItem() == null ) { |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.util; | |
| 3 | ||
| 4 | import java.util.LinkedHashMap; | |
| 5 | import java.util.Map; | |
| 6 | ||
| 7 | /** | |
| 8 | * A map that removes the oldest entry once its capacity (cache size) has | |
| 9 | * been reached. | |
| 10 | * | |
| 11 | * @param <K> The type of key mapped to a value. | |
| 12 | * @param <V> The type of value mapped to a key. | |
| 13 | */ | |
| 14 | public final class BoundedCache<K, V> extends LinkedHashMap<K, V> { | |
| 15 | private final int mCacheSize; | |
| 16 | ||
| 17 | /** | |
| 18 | * Constructs a new instance having a finite size. | |
| 19 | * | |
| 20 | * @param cacheSize The maximum number of entries. | |
| 21 | */ | |
| 22 | public BoundedCache( final int cacheSize ) { | |
| 23 | mCacheSize = cacheSize; | |
| 24 | } | |
| 25 | ||
| 26 | @Override | |
| 27 | protected boolean removeEldestEntry( final Map.Entry<K, V> eldest ) { | |
| 28 | return size() > mCacheSize; | |
| 29 | } | |
| 30 | } | |
| 31 | 1 |
| 134 | 134 | |
| 135 | 135 | // Ensure the invariant holds. |
| 136 | assert 0 <= result && result < size || size == 0 && result <= 0; | |
| 136 | assert 0 <= result && result < size || size == 0; | |
| 137 | 137 | |
| 138 | 138 | return result; |
| 2 | 2 | package com.keenwrite.util; |
| 3 | 3 | |
| 4 | import java.util.ArrayList; | |
| 4 | import java.util.LinkedList; | |
| 5 | 5 | import java.util.List; |
| 6 | 6 | import java.util.function.BiConsumer; |
| ... | ||
| 15 | 15 | * See <a href="https://stackoverflow.com/a/31754787/59087">source</a> for |
| 16 | 16 | * details. |
| 17 | * </p> | |
| 18 | 17 | * |
| 19 | 18 | * @param <MT> The mutable definition for the type of object to build. |
| ... | ||
| 35 | 34 | * Adds a modifier to call when building an instance. |
| 36 | 35 | */ |
| 37 | private final List<Consumer<MT>> mModifiers = new ArrayList<>(); | |
| 36 | private final List<Consumer<MT>> mModifiers = new LinkedList<>(); | |
| 37 | ||
| 38 | /** | |
| 39 | * Starting point for building an instance of a particular class. | |
| 40 | * | |
| 41 | * @param supplier Returns the instance to build. | |
| 42 | * @param <MT> The type of class to build. | |
| 43 | * @return A new {@link GenericBuilder} capable of populating data for an | |
| 44 | * instance of the class provided by the {@link Supplier}. | |
| 45 | */ | |
| 46 | public static <MT, IT> GenericBuilder<MT, IT> of( | |
| 47 | final Supplier<MT> supplier, final Function<MT, IT> immutable ) { | |
| 48 | return new GenericBuilder<>( supplier, immutable ); | |
| 49 | } | |
| 38 | 50 | |
| 39 | 51 | /** |
| 40 | 52 | * Constructs a new builder instance that is capable of populating values for |
| 41 | 53 | * any type of object. |
| 42 | 54 | * |
| 43 | 55 | * @param mutator Provides methods to use for setting object properties. |
| 44 | 56 | */ |
| 45 | 57 | protected GenericBuilder( |
| 46 | final Supplier<MT> mutator, final Function<MT, IT> immutable ) { | |
| 58 | final Supplier<MT> mutator, final Function<MT, IT> immutable ) { | |
| 47 | 59 | assert mutator != null; |
| 48 | 60 | assert immutable != null; |
| 49 | 61 | |
| 50 | 62 | mMutable = mutator; |
| 51 | 63 | mImmutable = immutable; |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Starting point for building an instance of a particular class. | |
| 56 | * | |
| 57 | * @param supplier Returns the instance to build. | |
| 58 | * @param <MT> The type of class to build. | |
| 59 | * @return A new {@link GenericBuilder} capable of populating data for an | |
| 60 | * instance of the class provided by the {@link Supplier}. | |
| 61 | */ | |
| 62 | public static <MT, IT> GenericBuilder<MT, IT> of( | |
| 63 | final Supplier<MT> supplier, final Function<MT, IT> immutable ) { | |
| 64 | return new GenericBuilder<>( supplier, immutable ); | |
| 65 | 64 | } |
| 66 | 65 | |
| ... | ||
| 74 | 73 | */ |
| 75 | 74 | public <V> GenericBuilder<MT, IT> with( |
| 76 | final BiConsumer<MT, V> consumer, final V value ) { | |
| 75 | final BiConsumer<MT, V> consumer, final V value ) { | |
| 76 | assert consumer != null; | |
| 77 | ||
| 77 | 78 | mModifiers.add( instance -> consumer.accept( instance, value ) ); |
| 79 | ||
| 78 | 80 | return this; |
| 79 | 81 | } |
| ... | ||
| 86 | 88 | public IT build() { |
| 87 | 89 | final var value = mMutable.get(); |
| 90 | ||
| 88 | 91 | mModifiers.forEach( modifier -> modifier.accept( value ) ); |
| 89 | 92 | mModifiers.clear(); |
| 93 | ||
| 90 | 94 | return mImmutable.apply( value ); |
| 91 | 95 | } |
| 1 | package com.keenwrite.util; | |
| 2 | ||
| 3 | import com.keenwrite.sigils.SigilOperator; | |
| 4 | import com.keenwrite.sigils.Sigils; | |
| 5 | ||
| 6 | import java.util.HashMap; | |
| 7 | import java.util.Map; | |
| 8 | import java.util.concurrent.ConcurrentHashMap; | |
| 9 | import java.util.regex.Pattern; | |
| 10 | ||
| 11 | import static java.lang.String.format; | |
| 12 | import static java.util.regex.Pattern.compile; | |
| 13 | import static java.util.regex.Pattern.quote; | |
| 14 | ||
| 15 | public class InterpolatingMap extends ConcurrentHashMap<String, String> { | |
| 16 | private static final int GROUP_DELIMITED = 1; | |
| 17 | ||
| 18 | /** | |
| 19 | * Used to override the default initial capacity in {@link HashMap}. | |
| 20 | */ | |
| 21 | private static final int INITIAL_CAPACITY = 1 << 8; | |
| 22 | ||
| 23 | public InterpolatingMap() { | |
| 24 | super( INITIAL_CAPACITY ); | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Interpolates all values in the map that reference other values by way | |
| 29 | * of key names. Performs a non-greedy match of key names delimited by | |
| 30 | * definition tokens. This operation modifies the map directly. | |
| 31 | * | |
| 32 | * @param operator Contains the opening and closing sigils that mark | |
| 33 | * where variable names begin and end. | |
| 34 | * @return {@code this} | |
| 35 | */ | |
| 36 | public Map<String, String> interpolate( final SigilOperator operator ) { | |
| 37 | sigilize( operator ); | |
| 38 | interpolate( operator.getSigils() ); | |
| 39 | return this; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Wraps each key in this map with the starting and ending sigils provided | |
| 44 | * by the given {@link SigilOperator}. This operation modifies the map | |
| 45 | * directly. | |
| 46 | * | |
| 47 | * @param operator Container for starting and ending sigils. | |
| 48 | */ | |
| 49 | private void sigilize( final SigilOperator operator ) { | |
| 50 | forEach( ( k, v ) -> put( operator.entoken( k ), v ) ); | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Interpolates all values in the map that reference other values by way | |
| 55 | * of key names. Performs a non-greedy match of key names delimited by | |
| 56 | * definition tokens. This operation modifies the map directly. | |
| 57 | * | |
| 58 | * @param sigils Contains the opening and closing sigils that mark | |
| 59 | * where variable names begin and end. | |
| 60 | */ | |
| 61 | private void interpolate( final Sigils sigils ) { | |
| 62 | final var pattern = compile( | |
| 63 | format( | |
| 64 | "(%s.*?%s)", quote( sigils.getBegan() ), quote( sigils.getEnded() ) | |
| 65 | ) | |
| 66 | ); | |
| 67 | ||
| 68 | replaceAll( ( k, v ) -> resolve( v, pattern ) ); | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Given a value with zero or more key references, this will resolve all | |
| 73 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 74 | * contain the key name. | |
| 75 | * | |
| 76 | * @param value Value containing zero or more key references. | |
| 77 | * @param pattern The regular expression pattern to match variable key names. | |
| 78 | * @return The given value with all embedded key references interpolated. | |
| 79 | */ | |
| 80 | private String resolve( String value, final Pattern pattern ) { | |
| 81 | final var matcher = pattern.matcher( value ); | |
| 82 | ||
| 83 | while( matcher.find() ) { | |
| 84 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 85 | final var mapValue = get( keyName ); | |
| 86 | final var keyValue = mapValue == null | |
| 87 | ? keyName | |
| 88 | : resolve( mapValue, pattern ); | |
| 89 | ||
| 90 | value = value.replace( keyName, keyValue ); | |
| 91 | } | |
| 92 | ||
| 93 | return value; | |
| 94 | } | |
| 95 | } | |
| 96 | 1 |
| 10 | 10 | |
| 11 | 11 | workspace.document=Document |
| 12 | workspace.document.title=Title Name | |
| 13 | workspace.document.title.desc=Full document title, or variable reference (e.g., '{{'book.title'}}'). | |
| 14 | workspace.document.title.title=Title | |
| 15 | workspace.document.author=Author Name | |
| 16 | workspace.document.author.desc=Full name of primary author, or variable reference (e.g., '{{'book.author'}}'). | |
| 17 | workspace.document.author.title=Name | |
| 18 | workspace.document.byline=Byline | |
| 19 | workspace.document.byline.desc=Author name, pen name, byline, pseudonym, or variable reference. | |
| 20 | workspace.document.byline.title=Name | |
| 21 | workspace.document.address=Address | |
| 22 | workspace.document.address.desc=Author mailing address, or variable reference. | |
| 23 | workspace.document.address.title=Address | |
| 24 | workspace.document.phone=Phone | |
| 25 | workspace.document.phone.desc=Author phone number, or variable reference. | |
| 26 | workspace.document.phone.title=Number | |
| 27 | workspace.document.email=Email | |
| 28 | workspace.document.email.desc=Author email address, or variable reference. | |
| 29 | workspace.document.email.title=Email | |
| 30 | workspace.document.keywords=Keywords | |
| 31 | workspace.document.keywords.desc=Comma-separated words relating to subject matter, or variable reference. | |
| 32 | workspace.document.keywords.title=Words | |
| 33 | workspace.document.copyright=Copyright | |
| 34 | workspace.document.copyright.desc=Continuous years of publication, or variable reference. | |
| 35 | workspace.document.copyright.title=Year(s) | |
| 36 | workspace.document.date=Publish Date | |
| 37 | workspace.document.date.desc=Date and time document was published, or variable reference. | |
| 38 | workspace.document.date.title=Timestamp | |
| 12 | ||
| 13 | workspace.document.meta=Document Metadata | |
| 14 | workspace.document.meta.desc=Keys must be alphabetic, values may use variables (e.g., '{{'book.title'}}'). | |
| 15 | workspace.document.meta.title=Pairs | |
| 39 | 16 | |
| 40 | 17 | workspace.editor=Editor |
| ... | ||
| 60 | 37 | workspace.r.script.desc=Script runs prior to executing R statements within the document. |
| 61 | 38 | workspace.r.dir=Working Directory |
| 62 | workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | |
| 39 | workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script. | |
| 63 | 40 | workspace.r.dir.title=Directory |
| 64 | 41 | workspace.r.delimiter.began=Delimiter Prefix |
| 7 | 7 | import com.keenwrite.preferences.Workspace; |
| 8 | 8 | import com.keenwrite.preview.HtmlPreview; |
| 9 | import com.keenwrite.sigils.Sigils; | |
| 10 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 11 | 9 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; |
| 12 | 10 | import javafx.application.Application; |
| 13 | 11 | import javafx.beans.property.SimpleObjectProperty; |
| 14 | import javafx.beans.property.SimpleStringProperty; | |
| 15 | 12 | import javafx.event.Event; |
| 16 | 13 | import javafx.event.EventHandler; |
| ... | ||
| 24 | 21 | import org.testfx.framework.junit5.Start; |
| 25 | 22 | |
| 26 | import static com.keenwrite.constants.Constants.DEF_DELIM_BEGAN_DEFAULT; | |
| 27 | import static com.keenwrite.constants.Constants.DEF_DELIM_ENDED_DEFAULT; | |
| 28 | 23 | import static com.keenwrite.util.FontLoader.initFonts; |
| 29 | 24 | |
| ... | ||
| 52 | 47 | final var workspace = new Workspace(); |
| 53 | 48 | final var mainPane = new SplitPane(); |
| 54 | ||
| 55 | final var began = new SimpleStringProperty( DEF_DELIM_BEGAN_DEFAULT ); | |
| 56 | final var ended = new SimpleStringProperty( DEF_DELIM_ENDED_DEFAULT ); | |
| 57 | final var sigils = new Sigils( began, ended ); | |
| 58 | final var operator = new YamlSigilOperator( sigils ); | |
| 59 | 49 | final var transformer = new YamlTreeTransformer(); |
| 60 | final var editor = new DefinitionEditor( transformer, operator ); | |
| 50 | final var editor = new DefinitionEditor( transformer ); | |
| 61 | 51 | |
| 62 | 52 | final var tabPane1 = new DetachableTabPane(); |
| 63 | 53 | tabPane1.addTab( "Editor", editor ); |
| 64 | 54 | |
| 65 | 55 | final var tabPane2 = new DetachableTabPane(); |
| 66 | final var tab21 = tabPane2.addTab( "Picker", new ColorPicker() ); | |
| 67 | final var tab22 = tabPane2.addTab( "Editor", | |
| 68 | new MarkdownEditor( workspace ) ); | |
| 56 | final var tab21 = | |
| 57 | tabPane2.addTab( "Picker", new ColorPicker() ); | |
| 58 | final var tab22 = | |
| 59 | tabPane2.addTab( "Editor", new MarkdownEditor( workspace ) ); | |
| 69 | 60 | tab21.setTooltip( new Tooltip( "Colour Picker" ) ); |
| 70 | 61 | tab22.setTooltip( new Tooltip( "Text Editor" ) ); |
| 71 | 62 | |
| 72 | 63 | final var tabPane3 = new DetachableTabPane(); |
| 73 | 64 | tabPane3.addTab( "Preview", new HtmlPreview( workspace ) ); |
| 74 | 65 | |
| 75 | 66 | editor.addTreeChangeHandler( mTreeHandler ); |
| 76 | 67 | |
| 77 | 68 | mainPane.getItems().addAll( tabPane1, tabPane2, tabPane3 ); |
| 78 | ||
| 79 | final var scene = new Scene( mainPane ); | |
| 80 | stage.setScene( scene ); | |
| 81 | 69 | |
| 70 | stage.setScene( new Scene( mainPane ) ); | |
| 82 | 71 | stage.show(); |
| 83 | 72 | } |
| 1 | package com.keenwrite.preferences; | |
| 2 | ||
| 3 | import org.junit.jupiter.api.Test; | |
| 4 | ||
| 5 | import static com.keenwrite.preferences.Key.key; | |
| 6 | import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 7 | ||
| 8 | /** | |
| 9 | * Test that {@link Key} hierarchies can be transformed into alternate data | |
| 10 | * models. | |
| 11 | */ | |
| 12 | class KeyTest { | |
| 13 | @Test | |
| 14 | public void test_String_ParentHierarchy_DotNotation() { | |
| 15 | final var keyRoot = key( "root" ); | |
| 16 | final var keyMeta = key( keyRoot, "meta" ); | |
| 17 | final var keyDate = key( keyMeta, "date" ); | |
| 18 | ||
| 19 | final var expected = "root.meta.date"; | |
| 20 | final var actual = keyDate.toString(); | |
| 21 | ||
| 22 | assertEquals( expected, actual ); | |
| 23 | } | |
| 24 | } | |
| 1 | 25 |
| 5 | 5 | import com.keenwrite.Caret; |
| 6 | 6 | import com.keenwrite.preferences.Workspace; |
| 7 | import com.keenwrite.preview.HtmlPreview; | |
| 8 | 7 | import com.keenwrite.processors.Processor; |
| 9 | 8 | import com.keenwrite.processors.ProcessorContext; |
| 10 | 9 | import com.keenwrite.processors.markdown.extensions.ImageLinkExtension; |
| 11 | 10 | import com.vladsch.flexmark.html.HtmlRenderer; |
| 12 | 11 | import com.vladsch.flexmark.parser.Parser; |
| 13 | import javafx.beans.property.SimpleObjectProperty; | |
| 14 | 12 | import javafx.stage.Stage; |
| 15 | 13 | import org.junit.jupiter.api.Test; |
| ... | ||
| 27 | 25 | import java.util.Map; |
| 28 | 26 | |
| 29 | import static com.keenwrite.ExportFormat.NONE; | |
| 27 | import static com.keenwrite.ExportFormat.XHTML_TEX; | |
| 30 | 28 | import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT; |
| 31 | 29 | import static java.lang.String.format; |
| ... | ||
| 43 | 41 | public class ImageLinkExtensionTest { |
| 44 | 42 | private static final Workspace sWorkspace = new Workspace( |
| 45 | getResource( "workspace.xml" ) ); | |
| 43 | getResourceFile( "workspace.xml" ) ); | |
| 46 | 44 | |
| 47 | 45 | private static final Map<String, String> IMAGES = new HashMap<>(); |
| ... | ||
| 76 | 74 | addUri( "https://" + URI_WEB ); |
| 77 | 75 | } |
| 78 | ||
| 79 | private HtmlPreview mPreview; | |
| 80 | 76 | |
| 81 | 77 | @Start |
| 82 | 78 | @SuppressWarnings( "unused" ) |
| 83 | 79 | private void start( final Stage stage ) { |
| 84 | mPreview = new HtmlPreview( sWorkspace ); | |
| 85 | 80 | } |
| 86 | 81 | |
| ... | ||
| 144 | 139 | * Creates a new {@link ProcessorContext} for the given file name path. |
| 145 | 140 | * |
| 146 | * @param documentPath Fully qualified path to the file name. | |
| 141 | * @param inputPath Fully qualified path to the file name. | |
| 147 | 142 | * @return A context used for creating new {@link Processor} instances. |
| 148 | 143 | */ |
| 149 | private ProcessorContext createProcessorContext( final Path documentPath ) { | |
| 150 | return new ProcessorContext( | |
| 151 | mPreview, | |
| 152 | new SimpleObjectProperty<>(), | |
| 153 | documentPath, | |
| 154 | null, | |
| 155 | NONE, | |
| 156 | sWorkspace, | |
| 157 | Caret.builder().build() | |
| 158 | ); | |
| 144 | private ProcessorContext createProcessorContext( final Path inputPath ) { | |
| 145 | return ProcessorContext | |
| 146 | .builder() | |
| 147 | .with( ProcessorContext.Mutator::setInputPath, inputPath ) | |
| 148 | .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX ) | |
| 149 | .with( ProcessorContext.Mutator::setWorkspace, sWorkspace ) | |
| 150 | .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() ) | |
| 151 | .build(); | |
| 159 | 152 | } |
| 160 | 153 | |
| ... | ||
| 180 | 173 | private static String getResource( final String path ) { |
| 181 | 174 | return toUri( path ).toString(); |
| 175 | } | |
| 176 | ||
| 177 | private static File getResourceFile( final String path ) { | |
| 178 | return new File( getResource( path ) ); | |
| 182 | 179 | } |
| 183 | 180 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import org.junit.jupiter.api.Test; | |
| 5 | ||
| 6 | import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 7 | ||
| 8 | /** | |
| 9 | * Responsible for simulating R variable injection. | |
| 10 | */ | |
| 11 | class RKeyOperatorTest { | |
| 12 | ||
| 13 | /** | |
| 14 | * Test that a key name becomes an R variable. | |
| 15 | */ | |
| 16 | @Test | |
| 17 | void test_Process_KeyName_Processed() { | |
| 18 | final var mOperator = new RKeyOperator(); | |
| 19 | final var expected = "v$a$b$c$d"; | |
| 20 | final var actual = mOperator.apply( "a.b.c.d" ); | |
| 21 | ||
| 22 | assertEquals( expected, actual ); | |
| 23 | } | |
| 24 | } | |
| 1 | 25 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.sigils; | |
| 3 | ||
| 4 | import javafx.beans.property.SimpleStringProperty; | |
| 5 | import javafx.beans.property.StringProperty; | |
| 6 | import org.junit.jupiter.api.Test; | |
| 7 | ||
| 8 | import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 9 | ||
| 10 | /** | |
| 11 | * Responsible for simulating R variable injection. | |
| 12 | */ | |
| 13 | class RSigilOperatorTest { | |
| 14 | ||
| 15 | private final SigilOperator mOperator = createRSigilOperator(); | |
| 16 | ||
| 17 | /** | |
| 18 | * Test that a key name becomes an R variable. | |
| 19 | */ | |
| 20 | @Test | |
| 21 | void test_Entoken_KeyName_Tokenized() { | |
| 22 | final var expected = "v$a$b$c$d"; | |
| 23 | final var actual = mOperator.entoken( "{{a.b.c.d}}" ); | |
| 24 | assertEquals( expected, actual ); | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Test that a key name becomes a viable R expression. | |
| 29 | */ | |
| 30 | @Test | |
| 31 | void test_Apply_KeyName_Expression() { | |
| 32 | final var expected = "`r#x(v$a$b$c$d)`"; | |
| 33 | final var actual = mOperator.apply( "v$a$b$c$d" ); | |
| 34 | assertEquals( expected, actual ); | |
| 35 | } | |
| 36 | ||
| 37 | private StringProperty createSigil( final String token ) { | |
| 38 | return new SimpleStringProperty( token ); | |
| 39 | } | |
| 40 | ||
| 41 | private Sigils createRSigils() { | |
| 42 | return createSigils( "x(", ")" ); | |
| 43 | } | |
| 44 | ||
| 45 | private Sigils createYamlSigils() { | |
| 46 | return createSigils( "{{", "}}" ); | |
| 47 | } | |
| 48 | ||
| 49 | private Sigils createSigils( final String began, final String ended ) { | |
| 50 | return new Sigils( createSigil( began ), createSigil( ended ) ); | |
| 51 | } | |
| 52 | ||
| 53 | private YamlSigilOperator createYamlSigilOperator() { | |
| 54 | return new YamlSigilOperator( createYamlSigils() ); | |
| 55 | } | |
| 56 | ||
| 57 | private RSigilOperator createRSigilOperator() { | |
| 58 | return new RSigilOperator( createRSigils(), createYamlSigilOperator() ); | |
| 59 | } | |
| 60 | } | |
| 61 | 1 |
| 92 | 92 | final var expectedSvg = g.toString(); |
| 93 | 93 | final var bytes = expectedSvg.getBytes(); |
| 94 | final var doc = parse( new ByteArrayInputStream( bytes ) ); | |
| 95 | final var actualSvg = toSvg( doc.getDocumentElement() ); | |
| 96 | 94 | |
| 97 | verifyImage( rasterizeString( actualSvg ) ); | |
| 95 | try( final var in = new ByteArrayInputStream( bytes ) ) { | |
| 96 | final var doc = parse( in ); | |
| 97 | final var actualSvg = toSvg( doc.getDocumentElement() ); | |
| 98 | ||
| 99 | verifyImage( rasterizeString( actualSvg ) ); | |
| 100 | } | |
| 98 | 101 | } |
| 99 | 102 |
| 1 | package com.keenwrite.util; | |
| 2 | ||
| 3 | import com.keenwrite.collections.CircularQueue; | |
| 4 | import org.junit.jupiter.api.Test; | |
| 5 | ||
| 6 | import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 7 | ||
| 8 | /** | |
| 9 | * Tests the {@link CircularQueue} class. | |
| 10 | */ | |
| 11 | public class CircularQueueTest { | |
| 12 | ||
| 13 | /** | |
| 14 | * Exercises the circularity aspect of the {@link CircularQueue}. | |
| 15 | * Confirms that the elements added can be subsequently overwritten. | |
| 16 | * This also checks that peek and remove functionality work as expected. | |
| 17 | */ | |
| 18 | @Test | |
| 19 | public void test_Add_ExceedMaxCapacity_FirstElementOverwritten() { | |
| 20 | final var CAPACITY = 5; | |
| 21 | final var OVERWRITE = 17; | |
| 22 | final var ELEMENTS = CAPACITY + OVERWRITE; | |
| 23 | final var queue = createQueue( CAPACITY, ELEMENTS ); | |
| 24 | ||
| 25 | assertEquals( CAPACITY, queue.size() ); | |
| 26 | ||
| 27 | for( int i = 0; i < CAPACITY; i++ ) { | |
| 28 | final var expected = | |
| 29 | ELEMENTS - ((((OVERWRITE - CAPACITY - 1) - i) % CAPACITY) + 1); | |
| 30 | ||
| 31 | assertEquals( expected, queue.peek() ); | |
| 32 | assertEquals( expected, queue.remove() ); | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Tests iterating over all elements in the {@link CircularQueue}. | |
| 38 | */ | |
| 39 | @Test | |
| 40 | public void test_Iterate_FullQueue_AllElementsNavigated() { | |
| 41 | final var CAPACITY = 101; | |
| 42 | final var queue = createQueue( CAPACITY, CAPACITY ); | |
| 43 | int actualCount = 0; | |
| 44 | ||
| 45 | for( final var ignored : queue ) { | |
| 46 | actualCount++; | |
| 47 | } | |
| 48 | ||
| 49 | assertEquals( CAPACITY, actualCount ); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Tests iterating over {@link CircularQueue} where some elements, | |
| 54 | * starting at an arbitrary offset, have been removed. | |
| 55 | */ | |
| 56 | @Test | |
| 57 | public void test_Iterate_PartialQueue_AllElementsNavigated() { | |
| 58 | final var CAPACITY = 31; | |
| 59 | final var OVERWRITE = CAPACITY / 2; | |
| 60 | final var queue = createQueue( CAPACITY, CAPACITY + OVERWRITE ); | |
| 61 | var actualCount = 0; | |
| 62 | ||
| 63 | for( int i = 0; i < OVERWRITE; i++ ) { | |
| 64 | queue.remove(); | |
| 65 | } | |
| 66 | ||
| 67 | for( final var ignored : queue ) { | |
| 68 | actualCount++; | |
| 69 | } | |
| 70 | ||
| 71 | assertEquals( CAPACITY - OVERWRITE, actualCount ); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Creates a new, pre-populated {@link CircularQueue} instance. | |
| 76 | * | |
| 77 | * @param capacity The maximum number of elements before overwriting. | |
| 78 | * @param count The number of elements to pre-populate the queue. | |
| 79 | * @return A new {@link CircularQueue} pre-populated with ascending, | |
| 80 | * consecutive values. | |
| 81 | */ | |
| 82 | private static CircularQueue<Integer> createQueue( | |
| 83 | final int capacity, final int count ) { | |
| 84 | final var queue = new CircularQueue<Integer>( capacity ); | |
| 85 | ||
| 86 | for( int i = 0; i < count; i++ ) { | |
| 87 | queue.add( i ); | |
| 88 | } | |
| 89 | ||
| 90 | return queue; | |
| 91 | } | |
| 92 | } | |
| 1 | 93 |